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