Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
210 lines
6.0 KiB
Go
210 lines
6.0 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"pad/internal/editor"
|
|
"pad/internal/test/e2e"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// editorTapHandler mimics the production tap/long-press/double-tap closure
|
|
// that layout attaches to the editor's TextField (a type switch over the
|
|
// gesture data). e2e injects it because the harness has no renderer to derive
|
|
// the gestures.
|
|
func editorTapHandler(data any) {
|
|
switch pt := data.(type) {
|
|
case ui.Point:
|
|
editor.HandleTapAt(pt.X, pt.Y)
|
|
case ui.DoubleTapPoint:
|
|
editor.HandleDoubleTapAt(pt.X, pt.Y)
|
|
case ui.LongPressPoint:
|
|
editor.HandleLongPressAt(pt.X, pt.Y)
|
|
}
|
|
}
|
|
|
|
// setupTouchE2E opens a real file, forces the just-opened tap guard into the
|
|
// past, installs a single-line synthetic GlyphLayout (11 glyphs at x=10*i) and
|
|
// returns the harness plus the editor region (app-local Dp).
|
|
func setupTouchE2E(t *testing.T) (*e2e.Harness, ui.Region) {
|
|
t.Helper()
|
|
content := "hello world\nsecond line\n"
|
|
h, _ := realFileHarness(t, "notes.txt", content)
|
|
h.SendConfig(780, 1688)
|
|
// Let the just-opened guard (300 ms) lapse so test taps are not swallowed.
|
|
time.Sleep(350 * time.Millisecond)
|
|
// Synthetic single-line layout for "hello world" (the visible window of a
|
|
// small file is the whole buffer, so window-relative == file-relative).
|
|
if err := h.WithState(func(st *editor.State) {
|
|
n := 11
|
|
bo := make([]int, n)
|
|
xs := make([]ui.Dp, n)
|
|
ys := make([]ui.Dp, n)
|
|
ad := make([]ui.Dp, n)
|
|
for i := 0; i < n; i++ {
|
|
bo[i] = i
|
|
xs[i] = ui.Dp(10 * i)
|
|
ys[i] = 0
|
|
ad[i] = 10
|
|
}
|
|
st.Editor.GlyphLayout = ui.GlyphLayout{ByteOffsets: bo, X: xs, Y: ys, Advance: ad}
|
|
}); err != nil {
|
|
t.Fatalf("set GlyphLayout: %v", err)
|
|
}
|
|
// Wait for a frame so EditorRegion is set, then read it.
|
|
if _, err := h.WaitForFrame(e2e.DefaultTimeout); err != nil {
|
|
t.Fatalf("wait for frame: %v", err)
|
|
}
|
|
regAny, err := h.Inspect(func(st *editor.State) any { return st.EditorRegion })
|
|
if err != nil {
|
|
t.Fatalf("inspect region: %v", err)
|
|
}
|
|
reg := regAny.(ui.Region)
|
|
if reg.W <= 0 {
|
|
t.Fatalf("editor region not laid out: %+v", reg)
|
|
}
|
|
return h, reg
|
|
}
|
|
|
|
// frameHasMenu reports whether any captured frame carries a Menu element.
|
|
func frameHasMenu(frames [][]ui.Element) bool {
|
|
for _, f := range frames {
|
|
for _, el := range f {
|
|
if el.Type() == "menu" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func selectionOf(t *testing.T, h *e2e.Harness) (int, int) {
|
|
t.Helper()
|
|
v, err := h.Inspect(func(st *editor.State) any {
|
|
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("inspect selection: %v", err)
|
|
}
|
|
s := v.([2]int)
|
|
return s[0], s[1]
|
|
}
|
|
|
|
func TestRealFile_TouchSelectionLongPressWord(t *testing.T) {
|
|
h, reg := setupTouchE2E(t)
|
|
defer h.Cleanup()
|
|
|
|
before := h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{{
|
|
Handler: editorTapHandler,
|
|
Data: ui.LongPressPoint{X: reg.X + 2, Y: reg.Y + 8}, // glyph 0 of "hello"
|
|
}})
|
|
frames, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout)
|
|
if err != nil {
|
|
t.Fatalf("wait frame: %v", err)
|
|
}
|
|
s, e := selectionOf(t, h)
|
|
if s != 0 || e != 5 {
|
|
t.Errorf("selection = [%d,%d), want [0,5) (\"hello\")", s, e)
|
|
}
|
|
if !frameHasMenu(frames) {
|
|
t.Error("no Menu element in frames after long press")
|
|
}
|
|
}
|
|
|
|
func TestRealFile_TouchSelectionDragEndHandle(t *testing.T) {
|
|
h, reg := setupTouchE2E(t)
|
|
defer h.Cleanup()
|
|
|
|
// Long press selects "hello".
|
|
before := h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{{
|
|
Handler: editorTapHandler,
|
|
Data: ui.LongPressPoint{X: reg.X + 2, Y: reg.Y + 8},
|
|
}})
|
|
if _, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout); err != nil {
|
|
t.Fatalf("wait frame: %v", err)
|
|
}
|
|
|
|
// Drag the end handle (which=1): grab near the anchor (byte 5 at local
|
|
// x=50), then move past the last glyph — the anchor follows the finger
|
|
// 1:1 and selects the line.
|
|
before = h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{
|
|
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 52, Y: reg.Y + 8}},
|
|
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 115, Y: reg.Y + 8}},
|
|
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEnd{}},
|
|
})
|
|
if _, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout); err != nil {
|
|
t.Fatalf("wait frame: %v", err)
|
|
}
|
|
s, e := selectionOf(t, h)
|
|
if s != 0 || e != 11 {
|
|
t.Errorf("selection = [%d,%d), want [0,11) after end-handle drag", s, e)
|
|
}
|
|
}
|
|
|
|
func TestRealFile_TouchSelectionMenuCopyPaste(t *testing.T) {
|
|
h, reg := setupTouchE2E(t)
|
|
defer h.Cleanup()
|
|
|
|
// Long press selects "hello" and shows the menu.
|
|
before := h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{{
|
|
Handler: editorTapHandler,
|
|
Data: ui.LongPressPoint{X: reg.X + 2, Y: reg.Y + 8},
|
|
}})
|
|
frames, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout)
|
|
if err != nil {
|
|
t.Fatalf("wait frame: %v", err)
|
|
}
|
|
if !frameHasMenu(frames) {
|
|
t.Fatal("no Menu element after long press")
|
|
}
|
|
|
|
// Read the menu geometry from the owner and tap the first item (Copy)
|
|
// at its center.
|
|
menuAny, err := h.Inspect(func(st *editor.State) any {
|
|
return struct {
|
|
R ui.Region
|
|
Items []ui.MenuItem
|
|
}{st.Editor.MenuRect, st.Editor.MenuItems}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("inspect menu: %v", err)
|
|
}
|
|
menu := menuAny.(struct {
|
|
R ui.Region
|
|
Items []ui.MenuItem
|
|
})
|
|
if len(menu.Items) == 0 || menu.R.W <= 0 {
|
|
t.Fatal("menu not visible with items")
|
|
}
|
|
tapX := menu.R.X + menu.Items[0].W/2
|
|
tapY := menu.R.Y + menu.R.H/2
|
|
before = h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{
|
|
{Handler: func(d any) {
|
|
p := d.(ui.Point)
|
|
editor.HandleMenuTap(p.X, p.Y)
|
|
}, Data: ui.Point{X: tapX, Y: tapY}},
|
|
})
|
|
if _, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout); err != nil {
|
|
t.Fatalf("wait frame: %v", err)
|
|
}
|
|
|
|
// The copy went to the clipboard channel; the harness has no main loop, so
|
|
// feed it back through PasteChan and verify the selection is replaced.
|
|
h.Logic().PasteChan() <- "XY"
|
|
got, err := h.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("full content: %v", err)
|
|
}
|
|
// "hello" (the selection) is replaced by "XY".
|
|
want := "XY world\nsecond line\n"
|
|
if got != want {
|
|
t.Errorf("content after paste = %q, want %q", got, want)
|
|
}
|
|
}
|