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.
208 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|