Selection handles now track the finger 1:1 (anchor grab point + displacement) instead of snapping by whole lines, and crossing the opposite handle flips the selection (native behaviour) instead of clearing it. Caret and tap/handle line resolution use VisualLineStarts instead of the min-Y baseline: the window's first visual line may be an empty line with no recorded glyphs, which used to draw boundary carets one line too low per leading empty line and land taps/dragged handles one line below the finger. New exported ui.CaretPoint centralises byte->insertion-point mapping. The off-screen caret no longer clamps to the window edge: EditorLayout ships the true (possibly negative / past-end) window-relative cursor and the renderer skips the caret when the cursor is outside the shaped window, so scrolling past the caret no longer makes it jump onto the top/bottom line. IME/router replay fixes: key.FocusCmd is issued only on a focus transition (a per-frame no-op still takes the immediate-command path and re-queues all pointer events), and the key.SelectionCmd IME sync is deferred while a handle drag is in progress (each push re-injected the drag into every gesture). Handle drags forward only Grabbed events; a tap inside a handle grab box is a no-op. Also: key.FocusEvent no longer logs as unexpected in main; dead code removed (worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses strings.HasPrefix; build scripts run the new scripts/check.sh static gate (go vet + staticcheck). Tests: caret_point_test, touch_selection updates (flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
721 lines
27 KiB
Go
721 lines
27 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)
|
|
// 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{})
|
|
}
|