Pad/internal/test/e2e/touch_selection_e2e_test.go
Greg Pomerantz 2a16c0017c Touch selection (v1): long-press/double-tap word selection, drag handles, floating copy/cut/paste menu
Implement the Android-native touch selection model, verified on-device:
- long-press selects the word under the finger (blank -> caret + paste-only
  menu); double-tap selects the word; drag handles resize the selection,
  drag the highlighted body to move it; floating menu offers copy/cut/paste
  (selection) or paste (bare caret), closing on any item tap.
- Renderer reports finger positions (app-local Dp) as tap/double-tap/
  long-press/selection-drag events; the logic goroutine owns all geometry
  (EditorRegion, menu rect, hit-testing, handles) and the renderer only
  draws the frame snapshot.
- Long press: 400 ms still-press on the editor, cancelled by movement
  (non-grabbing raw pointer probe) or by a scroll/handle grab. The main
  loop keeps invalidating while a press is pending (Gio renders on demand;
  a stationary finger produces no frames).
- Clipboard crosses the goroutine boundary via buffered channels
  (clipboardSetChan/pasteReqChan logic->main, pasteChan main->logic);
  main executes the Gio ops and, on Android, invalidates after ReadCmd
  because a queued transfer.DataEvent schedules no frame of its own.

Renderer fixes found while validating on-device:
- clickReg was stored by value in a map; range yielded copies so per-frame
  press bookkeeping (long-press state) was silently discarded. Now pointers.
- On Android a tap's press+release arrive in the same frame and
  gesture.Click/Drag return one event per Update call; without draining
  each gesture's queue every frame the release was lost on an idle window
  and every menu tap was swallowed (needed a second tap to 'rescue' it).
  Click and drag loops now drain to exhaustion (scroll already does).
- pointer.Filter queries must name Kinds: a zero-kinds filter matches
  nothing (the press-probe query was dead).
- Menu.Draw offsets items by the menu origin; the clippable drawElement
  branch registers SelDrag (handles now draw for the TextField).

Tests: internal/editor/touch_selection_test.go (word range, long-press,
double-tap, tap/menu guards, handle drags, menu actions, selection edits)
and internal/test/e2e/touch_selection_e2e_test.go; full suite green under
-race. Docs: spec.md §2.2 + §7, architecture.md §6.3a, development_plan.md
Phases 8-9.
2026-08-17 08:57:55 -04:00

208 lines
5.9 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) past the last glyph: selects the line.
before = h.FrameCount()
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 115, 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)
}
}