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) // Grab the end handle (which=1) near the anchor (byte 5 is at local // x=50): the grab itself does not move the selection... HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 108}) assertSelection(t, 0, 5) // ...and dragging past the last glyph (x=50+63=113 > 110) selects the // whole line: the anchor follows the finger 1:1. 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) // Grab the start handle (which=0) near the anchor (byte 0 at local x=0). HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 2, Y: 108}) assertSelection(t, 0, 5) // Drag right to x=0+43=43 (near glyph 4's centre 45): the start moves // to byte 4. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108}) assertSelection(t, 4, 5) } func TestSelDrag_StartHandleCrossesEndFlips(t *testing.T) { touchSelState("hello world") HandleLongPressAt(18, 108) // selects "hello" [0,5) // Grab the start handle near the anchor (byte 0 at local x=0). HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 2, Y: 108}) // Drag past the end handle (x=0+64=64 -> glyph 'w' at byte 6): the // selection flips (native behaviour) instead of clamping to zero length // and clearing. The dragged handle now controls the end it crossed. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 66, Y: 108}) assertSelection(t, 5, 6) // While flipped, dragging before the (new) start flips back (x=0+14=14 // -> byte 1). HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 16, Y: 108}) assertSelection(t, 1, 5) HandleSelDragEvt(ui.SelectionDragEnd{}) } func TestSelDrag_EndHandleCrossesStartFlips(t *testing.T) { touchSelState("hello world") HandleLongPressAt(16+62, 108) // selects "world" [6,11) // Grab the end handle near the anchor (byte 11 at local x=110). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 112, Y: 108}) // Drag before the start handle (x=110-96=14 -> glyph 'e' at byte 1): // the selection flips (native behaviour) instead of clamping to zero // length and clearing: it now spans the finger (byte 1) to the other // handle (6). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 16, Y: 108}) assertSelection(t, 1, 6) // While flipped, the dragged handle controls the start: dragging back // past the other handle (x=110-16=94 -> glyph 'l' at byte 9) un-flips, // and the handle becomes the end again: [6,9). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 96, Y: 108}) assertSelection(t, 6, 9) HandleSelDragEvt(ui.SelectionDragEnd{}) } 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") } // Grab the caret handle near the anchor (byte 12 at local x=120), then // drag left so the mapped point is x=120-97=23 (glyph 2's centre 25): // the caret follows the finger 1:1. HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 122, Y: 108}) if TheState.Editor.CursorPosition != 12 { t.Errorf("cursor = %d after grab, want 12 (unchanged)", TheState.Editor.CursorPosition) } 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). Grab 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. The anchor's reference is its own point (x=0, line 0) // fixed at the grab, so the grab and a zero-displacement event do not // move it — the selection 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, 0, 5) // Drag right by 20dp: the anchor moves to x=0+20=20 on its own line 0 — // exactly the left edge of the glyph for byte 2, which maps to its own // byte. The finger's own line is never used. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 42, Y: 100 + 25}) assertSelection(t, 2, 5) // Continue: x=0+40=40 is the edge of byte 4. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 62, Y: 100 + 25}) assertSelection(t, 4, 5) // "o" } // TestSelDrag_EndHandle_DragDownExtendsAcrossLines is the multi-line // extension: the anchor's mapped point (its grab position plus the finger's // displacement) crosses into the next visual 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: the mapped point (50, 8.4+20=28.4) is on line 1, where // x=50 is the left edge of the glyph for byte 17 (exact edges map to // their own byte). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 45}) assertSelection(t, 0, 17) // "hello world\nsecon" } // 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=2 -> byte 12. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 2, 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) } } // touchSelState3Lines is like touchSelState2Lines with a third line. func touchSelState3Lines() { content := "hello world\nsecond line\nthird line here\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) bo := []int{} xs := []ui.Dp{} s := []ui.Dp{} ad := []ui.Dp{} lines := []struct{ start, n int }{{0, 11}, {12, 11}, {24, 15}} for li, l := range lines { for j := 0; j < l.n; j++ { bo = append(bo, l.start+j) xs = append(xs, ui.Dp(10*j)) s = append(s, ui.Dp(float64(lh)*float64(li))) ad = append(ad, 10) } } TheState.Editor.GlyphLayout = ui.GlyphLayout{ LineHeight: lh, ByteOffsets: bo, X: xs, Y: s, Advance: ad, } } // TestSelDrag_EndHandle_JitterNearLineBoundary_Stable is a regression test // for a feedback runaway: the anchor's reference point is fixed AT THE // GRAB. Re-resolving it from the anchor's current byte on every event // makes the anchor's own movement feed into its target: once the anchor // crosses a line, every further event adds another line, and a jittering // finger near a boundary races the anchor to the bottom of the file. func TestSelDrag_EndHandle_JitterNearLineBoundary_Stable(t *testing.T) { touchSelState3Lines() HandleLongPressAt(18, 108) // selects "hello" [0,5) checkInitialSelection(t) // End handle (which=1), anchor byte 5 on line 0. Grab at local (52, 25). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 25}) checkInitialSelection(t) // The finger hovers just past the line boundary (the mapped point's y // = 18.4/19.4, past line 0's band of 0..16.8) and jitters. The anchor // must settle one line down (byte 17) and STAY there — not creep to line // 2 (byte 24+) or further with each event. for _, dy := range []int{35, 36, 35, 36, 35} { HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: ui.Dp(100 + dy)}) assertSelection(t, 0, 17) } } 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) } } // touchSelStateLines is like touchSelState but builds a genuine multi-line // layout from content: each non-newline byte becomes a 10dp-wide glyph at // x=10*column on its visual line (baseline y = line*lineH), and // VisualLineStarts records each visual line's first byte. Line-break glyphs // are absent from the arrays, as in the real renderer. func touchSelStateLines(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 TheState.Editor.IMEWindowStartByte = 0 TheState.Editor.IMEWindowText = content lh := ui.Dp(16.8) layout := ui.GlyphLayout{ LineHeight: lh, VisualLineStarts: []int{0}, } line, col := 0, 0 for i := 0; i < len(content); i++ { if content[i] == '\n' { layout.VisualLineStarts = append(layout.VisualLineStarts, i+1) line, col = line+1, 0 continue } layout.ByteOffsets = append(layout.ByteOffsets, i) layout.X = append(layout.X, ui.Dp(10*col)) layout.Y = append(layout.Y, ui.Dp(float64(lh)*float64(line))) layout.Advance = append(layout.Advance, 10) col++ } TheState.Editor.GlyphLayout = layout } // TestTextPos_EmptyLine is a regression test for selection anchors snapping // back to a single line when dragged across an empty line: a glyph-less // line has exactly one insertion point, its first byte, and must map there // (the old code clamped to the LAST non-empty line, so the anchor landed on // a neighbouring text line at the finger's x and the selection collapsed). func TestTextPos_EmptyLine(t *testing.T) { touchSelStateLines("abc\n\ndef\n") lh := float64(EffectiveLineHeight()) // 16.8 // Empty line 1's band: y in [16.8, 33.6). pos, ok := textPosFromLocalPoint(5, 1.5*lh) if !ok || pos != 4 { t.Errorf("textPosFromLocalPoint(5, 25.2) = %d, %v; want 4 (the empty line's lone insertion point)", pos, ok) } // The trailing empty line (after the last "\n") maps to EOF (byte 9). pos, ok = textPosFromLocalPoint(5, 3.5*lh) if !ok || pos != 9 { t.Errorf("textPosFromLocalPoint(5, 58.8) = %d, %v; want 9 (EOF)", pos, ok) } // Past the end of line 0's text: the line's terminating newline, byte 3. pos, ok = textPosFromLocalPoint(29, 0.5*lh) if !ok || pos != 3 { t.Errorf("textPosFromLocalPoint(29, 8.4) = %d, %v; want 3 (end of line 0)", pos, ok) } // Mid line 2: unchanged line behaviour (the 'e' of "def"). pos, ok = textPosFromLocalPoint(15, 2.5*lh) if !ok || pos != 6 { t.Errorf("textPosFromLocalPoint(15, 42) = %d, %v; want 6", pos, ok) } } // TestTextPos_EmptyFirstLine is the user-visible regression for the caret // jumping to an adjacent line: when the shaped WINDOW starts on an empty // line (no glyphs on visual line 0), the old (Y-minY)/lineHeight grouping // numbered every glyph line one too high, so a tap on visual line N landed // on line N+1 — the caret appeared one line below the finger, and it // "jumped back" when the window scrolled past the empty line. func TestTextPos_EmptyFirstLine(t *testing.T) { // Window text starting on an empty line (line 0), as when the viewport // top sits on an empty line: "\nabc\ndef\n" — line 0 empty (byte 0), // line 1 "abc" (bytes 1-3, "\n" at 4), line 2 "def" (bytes 5-7, "\n" at 8). touchSelStateLines("\nabc\ndef\n") lh := float64(EffectiveLineHeight()) // 16.8 // Tap on window line 1 (the "abc" line): the byte must be on line 1, // not line 2 (the old off-by-one landing). pos, ok := textPosFromLocalPoint(5, 1.5*lh) if !ok || pos != 1 { t.Errorf("tap on line 1 = %d, %v; want 1 (line 1's first glyph; the old code gave 5, line 2)", pos, ok) } // Tap past the end of line 1's text: line 1's terminating "\n" (byte 4), // not line 2's. pos, ok = textPosFromLocalPoint(29, 1.5*lh) if !ok || pos != 4 { t.Errorf("tap right of line 1 = %d, %v; want 4 (line 1 end); the old code gave 8 (line 2 end)", pos, ok) } // Tap on the empty FIRST line: its lone insertion point, byte 0 (the old // code clamped onto line 1's text). pos, ok = textPosFromLocalPoint(5, 0.5*lh) if !ok || pos != 0 { t.Errorf("tap on empty line 0 = %d, %v; want 0 (the empty line's lone insertion point)", pos, ok) } // Tap on window line 2: unaffected beyond the renumbering fix — line 2's // first glyph (byte 5). pos, ok = textPosFromLocalPoint(5, 2.5*lh) if !ok || pos != 5 { t.Errorf("tap on line 2 = %d, %v; want 5", pos, ok) } } // TestSelDrag_EndHandleAcrossEmptyLine is the user-visible regression: // dragging the end handle down across an empty line must sweep the selection // up to the empty line (its lone insertion point) and then continue onto // the text after it — not snap back to a single line. func TestSelDrag_EndHandleAcrossEmptyLine(t *testing.T) { touchSelStateLines("abc\n\ndef\n") HandleLongPressAt(16+5, 100+8) // selects "abc" [0,3) assertSelection(t, 0, 3) // Grab the end handle: the anchor is at byte 3 (end of line 0: x=30, // y=8.4, the line's middle). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 30, Y: 100 + 8.4}) assertSelection(t, 0, 3) // Drag down into the empty line's band: the end sweeps to the empty // line's insertion point (byte 4) instead of snapping back. HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 30, Y: 100 + 25.2}) assertSelection(t, 0, 4) // Continue onto line 2: the end follows the finger to the end of "def" // (byte 8). HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 30, Y: 100 + 42}) assertSelection(t, 0, 8) HandleSelDragEvt(ui.SelectionDragEnd{}) // Grab the start handle at byte 0 (line 0: x=0, y=8.4) and drag it down // onto the empty line: it sweeps to the empty line's insertion point // (byte 4) and the selection shrinks from [0,8) to [4,8) — no collapse, // no snap back. HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 8.4}) HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 25.2}) assertSelection(t, 4, 8) HandleSelDragEvt(ui.SelectionDragEnd{}) }