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.
369 lines
12 KiB
Go
369 lines
12 KiB
Go
package editor
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// touchSelState sets up a fresh string-buffer state with a single-line
|
|
// GlyphLayout: 11 glyphs at x=10*i (advance 10), line top at y=0.
|
|
// EditorRegion is {X:16, Y:100, W:379, H:700} so text-local x = pt.X-16 and
|
|
// tapLocalY(y,100,0) = pt.Y-100 (scroll offset 0).
|
|
func touchSelState(content string) {
|
|
TheState = NewState()
|
|
TheState.Editor.Buffer = content
|
|
TheState.Editor.CursorPosition = len(content)
|
|
TheState.EditorRegion = ui.Region{X: 16, Y: 100, W: 379, H: 700}
|
|
TheState.ScrollOffset = 0
|
|
// The visible window (what layoutFrame sets every frame): the whole
|
|
// string buffer. bytePosToScreenXY bounds-checks against it.
|
|
TheState.Editor.IMEWindowStartByte = 0
|
|
TheState.Editor.IMEWindowText = content
|
|
n := len(content)
|
|
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
|
|
}
|
|
TheState.Editor.GlyphLayout = ui.GlyphLayout{
|
|
ByteOffsets: bo,
|
|
X: xs,
|
|
Y: ys,
|
|
Advance: ad,
|
|
}
|
|
}
|
|
|
|
// assertMenu checks menu visibility and item icons.
|
|
func assertMenu(t *testing.T, wantVisible bool, wantIcons ...string) {
|
|
t.Helper()
|
|
e := TheState.Editor
|
|
if e.MenuVisible != wantVisible {
|
|
t.Fatalf("MenuVisible = %v, want %v", e.MenuVisible, wantVisible)
|
|
}
|
|
if wantVisible {
|
|
if len(e.MenuItems) != len(wantIcons) {
|
|
t.Fatalf("menu has %d items, want %d", len(e.MenuItems), len(wantIcons))
|
|
}
|
|
for i, it := range e.MenuItems {
|
|
if it.Icon != wantIcons[i] {
|
|
t.Errorf("menu item %d icon = %q, want %q", i, it.Icon, wantIcons[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWordRangeAt(t *testing.T) {
|
|
touchSelState("hello world")
|
|
|
|
// Inside a word.
|
|
s, e, ok := wordRangeAt(3)
|
|
if !ok || s != 0 || e != 5 {
|
|
t.Errorf("wordRangeAt(3) = [%d,%d) ok=%v, want [0,5) true", s, e, ok)
|
|
}
|
|
// Second word.
|
|
s, e, ok = wordRangeAt(7)
|
|
if !ok || s != 6 || e != 11 {
|
|
t.Errorf("wordRangeAt(7) = [%d,%d) ok=%v, want [6,11) true", s, e, ok)
|
|
}
|
|
// Word boundary: position right after a word (space after) still finds
|
|
// the word ending there.
|
|
s, e, ok = wordRangeAt(5)
|
|
if !ok || s != 0 || e != 5 {
|
|
t.Errorf("wordRangeAt(5) = [%d,%d) ok=%v, want [0,5) true", s, e, ok)
|
|
}
|
|
// EOF: the position is right after the last word -> that word.
|
|
s, e, ok = wordRangeAt(11)
|
|
if !ok || s != 6 || e != 11 {
|
|
t.Errorf("wordRangeAt(11) = [%d,%d) ok=%v, want [6,11) true", s, e, ok)
|
|
}
|
|
}
|
|
|
|
func TestWordRangeAt_PunctuationAndIdentifiers(t *testing.T) {
|
|
touchSelState("foo,bar_baz 123")
|
|
// "bar_baz" is one word (underscore joins).
|
|
s, e, ok := wordRangeAt(7)
|
|
if !ok || s != 4 || e != 11 {
|
|
t.Errorf("wordRangeAt(7) = [%d,%d) ok=%v, want [4,11) true", s, e, ok)
|
|
}
|
|
// "123".
|
|
s, e, ok = wordRangeAt(14)
|
|
if !ok || s != 12 || e != 15 {
|
|
t.Errorf("wordRangeAt(14) = [%d,%d) ok=%v, want [12,15) true", s, e, ok)
|
|
}
|
|
// Position at the comma, right after "foo": the word ending there.
|
|
s, e, ok = wordRangeAt(3)
|
|
if !ok || s != 0 || e != 3 {
|
|
t.Errorf("wordRangeAt(3) = [%d,%d) ok=%v, want [0,3) true", s, e, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandleLongPressAt_SelectsWord(t *testing.T) {
|
|
touchSelState("hello world")
|
|
// Long press on the first glyph of "hello" (window pt: x=16+2=18, y=108).
|
|
HandleLongPressAt(18, 108)
|
|
assertSelection(t, 0, 5)
|
|
assertMenu(t, true, "copy", "cut", "paste")
|
|
if TheState.Editor.SelectionAnchor != 0 {
|
|
t.Errorf("anchor = %d, want 0", TheState.Editor.SelectionAnchor)
|
|
}
|
|
if TheState.Editor.CursorPosition != 5 {
|
|
t.Errorf("cursor = %d, want 5", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleLongPressAt_BlankGivesCaretDrag(t *testing.T) {
|
|
// Line ends with punctuation so the position right after the last glyph
|
|
// is NOT adjacent to a word rune (a blank spot, not a word edge).
|
|
touchSelState("hello world.")
|
|
// Long press far right of the last glyph (x=16+150=166, past x=120).
|
|
HandleLongPressAt(166, 108)
|
|
assertSelection(t, -1, -1)
|
|
if !TheState.Editor.CaretDrag {
|
|
t.Errorf("CaretDrag = false, want true (long press on blank)")
|
|
}
|
|
// No selection -> the menu offers only Paste.
|
|
assertMenu(t, true, "paste")
|
|
if TheState.Editor.CursorPosition != 12 {
|
|
t.Errorf("cursor = %d, want 12 (end of line)", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleLongPressAt_AfterLastWordSelectsIt(t *testing.T) {
|
|
// A long press in the blank just past the last word selects that word
|
|
// (the word adjacent to the tap), matching Android's word selection.
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(166, 108)
|
|
assertSelection(t, 6, 11)
|
|
}
|
|
|
|
func TestHandleLongPressAt_InGapBetweenWords(t *testing.T) {
|
|
touchSelState("hello world") // two spaces
|
|
// Long press on the SECOND space (glyph 6, center x=65; pt 16+65=81):
|
|
// no word rune at or immediately before the position -> caret.
|
|
HandleLongPressAt(81, 108)
|
|
assertSelection(t, -1, -1)
|
|
if !TheState.Editor.CaretDrag {
|
|
t.Errorf("CaretDrag = false, want true (long press in word gap)")
|
|
}
|
|
if TheState.Editor.CursorPosition != 6 {
|
|
t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleDoubleTapAt_SelectsWord(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleDoubleTapAt(18, 108)
|
|
assertSelection(t, 0, 5)
|
|
assertMenu(t, true, "copy", "cut", "paste")
|
|
}
|
|
|
|
func TestHandleTapAt_MovesCaretAndClearsSelection(t *testing.T) {
|
|
touchSelState("hello world")
|
|
// Establish a selection, then a plain tap clears it and moves the caret.
|
|
HandleLongPressAt(18, 108)
|
|
assertSelection(t, 0, 5)
|
|
// Tap on the 'w' of "world" (glyph 6, x=60..70; local 62 -> pt 78).
|
|
HandleTapAt(78, 108)
|
|
assertSelection(t, -1, -1)
|
|
if TheState.Editor.CaretDrag {
|
|
t.Errorf("CaretDrag = true after plain tap, want false")
|
|
}
|
|
if TheState.Editor.CursorPosition != 6 {
|
|
t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleTapAt_InsideMenuIsIgnored(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108)
|
|
if !TheState.Editor.MenuVisible {
|
|
t.Fatal("expected menu visible")
|
|
}
|
|
posBefore := TheState.Editor.CursorPosition
|
|
selStartBefore := TheState.Editor.SelectionStart
|
|
// Tap inside the menu (top-left item).
|
|
m := TheState.Editor.MenuRect
|
|
HandleTapAt(m.X+5, m.Y+5)
|
|
if TheState.Editor.CursorPosition != posBefore {
|
|
t.Errorf("cursor moved to %d after tap in menu, want %d (ignored)", TheState.Editor.CursorPosition, posBefore)
|
|
}
|
|
if TheState.Editor.SelectionStart != selStartBefore {
|
|
t.Errorf("selection changed after tap in menu")
|
|
}
|
|
if !TheState.Editor.MenuVisible {
|
|
t.Errorf("menu hidden after tap in menu, want still visible")
|
|
}
|
|
}
|
|
|
|
func TestSelDrag_EndHandleExtends(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello" [0,5)
|
|
// Drag the end handle (which=1) past the last glyph (local 115 > 110)
|
|
// so the whole line is selected.
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 115, Y: 108})
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 115, Y: 108})
|
|
assertSelection(t, 0, 11)
|
|
}
|
|
|
|
func TestSelDrag_StartHandleExtends(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello" [0,5)
|
|
// Drag the start handle (which=0) to the 'o' (glyph 4, x=40..50; local 45 -> pt 61).
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108})
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108})
|
|
assertSelection(t, 4, 5)
|
|
}
|
|
|
|
func TestSelDrag_StartHandleCollapseClears(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello" [0,5)
|
|
// Drag the start handle to the end of the selection -> zero-length -> cleared.
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 52, Y: 108})
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 52, Y: 108})
|
|
assertSelection(t, -1, -1)
|
|
assertMenu(t, false)
|
|
}
|
|
|
|
func TestSelDrag_BodyMovesPreservingLength(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello" [0,5)
|
|
// Grab the body above the 'l' (glyph 2, x=20..30; local 25 -> pt 41):
|
|
// SelDragRel = pos(2) - selStart(0) = 2.
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 2, X: 16 + 25, Y: 108})
|
|
// Move the body so the finger is above the 'r' of world (glyph 9, x=90..100; local 95 -> pt 111):
|
|
// pos = 9 -> new start = 9-2 = 7 -> [7,12) clamped to [7,11)...
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 2, X: 16 + 95, Y: 108})
|
|
s := TheState.Editor
|
|
if s.SelectionStart != 6 || s.SelectionEnd != 11 {
|
|
// length 5 preserved, clamped at EOF: [6,11)
|
|
t.Errorf("body drag selection = [%d,%d), want [6,11)", s.SelectionStart, s.SelectionEnd)
|
|
}
|
|
HandleSelDragEvt(ui.SelectionDragEnd{})
|
|
}
|
|
|
|
func TestSelDrag_CaretHandleMovesCursor(t *testing.T) {
|
|
touchSelState("hello world.") // punctuation keeps the right side blank
|
|
HandleLongPressAt(166, 108) // blank -> caret drag at 12
|
|
if !TheState.Editor.CaretDrag {
|
|
t.Fatal("expected caret drag mode")
|
|
}
|
|
// Drag the caret handle (which=3) to glyph 2 (local 25 -> pt 41).
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 25, Y: 108})
|
|
HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 25, Y: 108})
|
|
if TheState.Editor.CursorPosition != 2 {
|
|
t.Errorf("cursor = %d after caret drag, want 2", TheState.Editor.CursorPosition)
|
|
}
|
|
// The handle stays up while dragging...
|
|
if !TheState.Editor.CaretDrag {
|
|
t.Errorf("CaretDrag cleared mid-drag, want true")
|
|
}
|
|
HandleSelDragEvt(ui.SelectionDragEnd{})
|
|
if TheState.Editor.CaretDrag {
|
|
t.Errorf("CaretDrag not cleared on drag end")
|
|
}
|
|
}
|
|
|
|
func TestMenuCopy(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello"
|
|
m := TheState.Editor.MenuRect
|
|
// Tap the copy item (first item, X offset 0).
|
|
HandleMenuTap(m.X+10, m.Y+10)
|
|
select {
|
|
case text := <-TheState.clipboardSetChan:
|
|
if text != "hello" {
|
|
t.Errorf("clipboard = %q, want %q", text, "hello")
|
|
}
|
|
default:
|
|
t.Fatal("no clipboard write after copy")
|
|
}
|
|
// Copy does not clear the selection (the highlight stays), but the
|
|
// menu closes, as on Android.
|
|
assertSelection(t, 0, 5)
|
|
assertMenu(t, false)
|
|
}
|
|
|
|
func TestMenuCut(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello"
|
|
m := TheState.Editor.MenuRect
|
|
// Tap the cut item (second item).
|
|
HandleMenuTap(m.X+ui.Dp(menuItemW)+10, m.Y+10)
|
|
select {
|
|
case text := <-TheState.clipboardSetChan:
|
|
if text != "hello" {
|
|
t.Errorf("clipboard = %q, want %q", text, "hello")
|
|
}
|
|
default:
|
|
t.Fatal("no clipboard write after cut")
|
|
}
|
|
if got := TheState.Editor.Buffer; got != " world" {
|
|
t.Errorf("buffer = %q after cut, want %q", got, " world")
|
|
}
|
|
assertSelection(t, -1, -1)
|
|
assertMenu(t, false)
|
|
if TheState.Editor.CursorPosition != 0 {
|
|
t.Errorf("cursor = %d after cut, want 0", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestMenuPaste(t *testing.T) {
|
|
touchSelState("hello")
|
|
HandleLongPressAt(18, 108) // selects "hello"
|
|
// Paste replaces the live selection (via the same path the Run loop uses
|
|
// when the main goroutine forwards clipboard content).
|
|
HandlePaste("XY")
|
|
if got := TheState.Editor.Buffer; got != "XY" {
|
|
t.Errorf("buffer = %q after paste, want %q", got, "XY")
|
|
}
|
|
assertSelection(t, -1, -1)
|
|
assertMenu(t, false)
|
|
if TheState.Editor.CursorPosition != 2 {
|
|
t.Errorf("cursor = %d after paste, want 2", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestMenuPaste_NoSelectionInsertsAtCursor(t *testing.T) {
|
|
touchSelState("hello")
|
|
TheState.Editor.CursorPosition = 5
|
|
HandlePaste(",")
|
|
if got := TheState.Editor.Buffer; got != "hello," {
|
|
t.Errorf("buffer = %q after paste, want %q", got, "hello,")
|
|
}
|
|
if TheState.Editor.CursorPosition != 6 {
|
|
t.Errorf("cursor = %d after paste, want 6", TheState.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestMenuTapPaste_HidesMenuAndRequestsRead(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello"
|
|
m := TheState.Editor.MenuRect
|
|
// Tap the paste item (third item).
|
|
HandleMenuTap(m.X+2*ui.Dp(menuItemW)+10, m.Y+10)
|
|
select {
|
|
case <-TheState.pasteReqChan:
|
|
default:
|
|
t.Fatal("no paste request after menu paste tap")
|
|
}
|
|
// The menu closes immediately (the read is asynchronous); the
|
|
// selection stays until the pasted content arrives.
|
|
assertMenu(t, false)
|
|
assertSelection(t, 0, 5)
|
|
}
|
|
|
|
func TestSelectionEdit_CutThenTypeReplaces(t *testing.T) {
|
|
touchSelState("hello world")
|
|
HandleLongPressAt(18, 108) // selects "hello"
|
|
HandleInsert("goodbye")
|
|
if got := TheState.Editor.Buffer; got != "goodbye world" {
|
|
t.Errorf("buffer = %q, want %q", got, "goodbye world")
|
|
}
|
|
}
|