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) } }