The relative-line mapping resolved the anchor's base line from the anchor's CURRENT byte on every drag event. Once the anchor crossed a line, its own movement fed back into its target: a finger jittering near a line boundary added one more line per event and raced the anchor to the bottom of the file (reported: tiny vertical movements jump the anchor off-screen). Capture the anchor's visual line once at the grab (SelDragPressLine) and compute the target from that fixed base. A jitter test (TestSelDrag_EndHandle_JitterNearLineBoundary_Stable) pins the runaway: mutation-verified against the per-event re-resolution.
546 lines
20 KiB
Go
546 lines
20 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)
|
|
}
|
|
}
|
|
|
|
// 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 target line must be computed from the
|
|
// anchor's line AT THE GRAB. Re-resolving the base line 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 half-line boundary (dY = 10, 11, 10
|
|
// around the 8.4 threshold) and jitters. The anchor must settle one line
|
|
// down (byte 17, the 'n' of "second") and STAY there — not creep to
|
|
// line 2 (byte 29+) 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)
|
|
}
|
|
}
|