Pad/internal/editor/touch_selection_test.go
Greg Pomerantz 7239b1e7ae Handle drags: restore cross-line selection via relative line mapping
The line-lock from the previous fix made anchors immovable across lines —
dragging a handle vertically no longer extended the selection, losing the
native multi-line behaviour.

New contract: a handle anchor moves RELATIVE to its own visual line. The
finger's vertical displacement from the grab position, in whole visual line
heights, selects the target line (under half a line: the anchor's own
line); the finger's x is projected onto that line. A stationary or
horizontal drag never moves the anchor off its line (the grab-box press
stays safe — the original bug is gone), while a deliberate vertical drag
walks the anchor across lines. The mapping is relative, not the finger's
absolute line, because the 48dp grab box is centred below the line: a low
press tracking the finger's absolute line would first drag the anchor the
wrong way, through a collapse, before reaching the anchor's line.

Verified on device: end handle dragged down extends the selection across
the newline; start handle dragged up extends it onto the previous line;
horizontal drags still shrink along the line without clearing. Tests
mutation-verified against both the pre-fix mapping and the
absolute-finger-line variant. Docs section 17 updated.
2026-08-18 13:39:25 -04:00

489 lines
17 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")
}
}
// touchSelState2Lines sets up the same geometry as touchSelState but with a
// second line beneath the first: "hello world\nsecond line\n". Line 0 glyphs
// (bytes 0..10) sit at Y=0; line 1 glyphs (bytes 12..22) at Y=lineHeight.
// The 48dp handle grab box of a line-0 handle straddles into line 1's row,
// which is what the line-projection regression tests rely on.
func touchSelState2Lines() {
content := "hello world\nsecond line\n"
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
TheState.Editor.IMEWindowStartByte = 0
TheState.Editor.IMEWindowText = content
lh := ui.Dp(16.8) // EditorLineHeight at the default font scale
bo := []int{}
xs := []ui.Dp{}
s := []ui.Dp{}
ad := []ui.Dp{}
for i := 0; i < 11; i++ { // line 0: bytes 0..10
bo = append(bo, i)
xs = append(xs, ui.Dp(10*i))
s = append(s, 0)
ad = append(ad, 10)
}
for j := 0; j < 11; j++ { // line 1: bytes 12..22
bo = append(bo, 12+j)
xs = append(xs, ui.Dp(10*j))
s = append(s, lh)
ad = append(ad, 10)
}
TheState.Editor.GlyphLayout = ui.GlyphLayout{
LineHeight: lh,
ByteOffsets: bo,
X: xs,
Y: s,
Advance: ad,
}
}
// TestSelDrag_StartHandle_PressOnLineBelow_KeepsSelection is a regression
// test: the start handle's 48dp grab box is centred BELOW the line (the
// teardrop hangs off the line's bottom edge), so a press in the lower half
// of the box lands on the neighbouring line. The press (zero displacement
// from the grab) must not move the anchor off its own line; mapping it to
// the finger's own line used to clamp the anchor onto the other handle and
// clear the selection on the very first drag event.
func TestSelDrag_StartHandle_PressOnLineBelow_KeepsSelection(t *testing.T) {
touchSelState2Lines()
HandleLongPressAt(18, 108) // selects "hello" [0,5)
checkInitialSelection(t)
// Start handle (which=0), anchor at byte 0 (local x=0). Press at local
// (22, 25): localY 25 is on line 1 (line height 16.8), on the finger's
// own line x=22 is byte 14 (past SelectionEnd=5) — the original bug's
// exact geometry. Zero vertical displacement from the grab -> the anchor
// stays on line 0, x=22 there is byte 2: the selection shrinks to "llo"
// but is never cleared.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 22, Y: 100 + 25})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 22, Y: 100 + 25})
assertSelection(t, 2, 5)
// Continue dragging left: x=12 on line 0 is byte 1.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 12, Y: 100 + 25})
assertSelection(t, 1, 5) // "ello"
}
// TestSelDrag_EndHandle_DragDownExtendsAcrossLines is the multi-line
// extension: grabbing the end handle and dragging it down more than half a
// line height moves the anchor to the next visual line (relative to the
// anchor's own line), so the selection grows across the newline — the
// native behaviour.
func TestSelDrag_EndHandle_DragDownExtendsAcrossLines(t *testing.T) {
touchSelState2Lines()
HandleLongPressAt(18, 108) // selects "hello" [0,5)
checkInitialSelection(t)
// End handle (which=1), anchor at byte 5 (local x=50). Grab at local
// (52, 25) — the teardrop hangs below line 0, its box centre at y≈26.8.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 25})
assertSelection(t, 0, 5) // grab alone does not move the anchor
// Drag down 20dp (past half a line height of 16.8): the anchor crosses
// to line 1, x=52 there is byte 17 (the 'n' of "second").
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 45})
assertSelection(t, 0, 17) // "hello world\nsecond" minus the last d
}
// TestSelDrag_StartHandle_DragUpExtendsToLineAbove is the upward
// counterpart: grabbing the line-1 start handle (whose box sits on line 2)
// and dragging it up extends the selection onto line 0.
func TestSelDrag_StartHandle_DragUpExtendsToLineAbove(t *testing.T) {
touchSelState2Lines()
HandleLongPressAt(16+15, 100+25) // line 1, x=15: selects "second" [12,18)
checkSelection12_18(t)
// Start handle (which=0), anchor at byte 12 (line 1, local x=0). Grab at
// local (0, 43) — the box centre for a line-1 handle is at y≈43.6.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 43})
checkSelection12_18(t)
// A small wobble (3dp) must NOT cross lines: still line 1, x=8 -> byte 12.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 8, Y: 100 + 46})
checkSelection12_18(t)
// Drag up 20dp from the grab: the anchor crosses to line 0, x=0 there is
// byte 0.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 23})
assertSelection(t, 0, 18) // "hello world\nsecond"
}
func checkSelection12_18(t *testing.T) {
t.Helper()
s := TheState.Editor
if s.SelectionStart != 12 || s.SelectionEnd != 18 {
t.Fatalf("selection = [%d,%d), want [12,18)", s.SelectionStart, s.SelectionEnd)
}
}
func checkInitialSelection(t *testing.T) {
t.Helper()
s := TheState.Editor
if s.SelectionStart != 0 || s.SelectionEnd != 5 {
t.Fatalf("precondition: selection = [%d,%d), want [0,5)", s.SelectionStart, s.SelectionEnd)
}
}