Pad/internal/editor/selection_menu_track_test.go
Greg Pomerantz 275a78efaa Fix selection-handle drags, menu anchoring, and left-edge back-gesture theft
Three user-reported selection bugs, one root cause each:

1. Start handle ungrabbable at line start. Two interacting causes:
   a) The 48dp grab box straddles two visual lines; a finger in the
      lower half mapped (by y-to-line) to the neighbouring line, whose
      byte past the other handle clamped to a zero-length selection ->
      cleared on the first drag event. The cleared selection
      un-registered the drag op, so the router silently stopped
      delivering drag events (the observed 'stream cutoff'). Fix:
      handle drags now project the finger's x onto the anchor's own
      visual line (visualLineOfByte + textPosOnLineAtX); the anchor
      never crosses lines during a handle drag.
   b) A horizontal flick from the line-start handle (screen x~26px)
      started the system back gesture, which cancelled the touch
      stream. Fix: report the handle grab rects as system gesture
      exclusion rects (setSystemGestureExclusionRects, API 29+),
      marshalled to the UI thread via a PadExcl smali Runnable
      (generated identically by build_emu.sh/build_phone.sh).

2. End-handle drag downward made the menu chase the finger and cover
   the selection. Fix: the menu anchors to the STABLE end of the
   selection (the end not being dragged), so it stays parked by the
   selection start, clear of the finger and the highlighted text.

3. Menu above the selection vanished permanently when the selection
   was extended onto the top line. Fix: off-window anchors no longer
   hide the menu while any part of the selection is visible (keep-last
   rect, clamped); hiding happens only for fully off-window selections.

Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
2026-08-18 13:09:29 -04:00

304 lines
11 KiB
Go

package editor
import (
"math"
"strings"
"testing"
"pad/internal/ui"
)
// dpeq compares two Dp values within 0.01 Dp (all menu geometry is
// float32 Dp; this is far below one screen pixel at any density).
func dpeq(a, b ui.Dp) bool { return math.Abs(float64(a)-float64(b)) < 0.01 }
// menuTrackState builds a small-file state: a 200-line buffer ("aa" per
// line), a selection on line 5 (bytes [15,17)), and a shaped GlyphLayout
// covering the WHOLE buffer (the small-file window is the whole buffer, so
// the layout is identical before and after a scroll — the scroll changes
// only k/r, exactly as in production). Baselines use the shaper's real
// convention (drawWrappedText): ascent + lineHeight*lineIndex, ascent = 14.
func menuTrackState(t *testing.T) (lh float64) {
const ascent float64 = 14 // FontSize; 0 < ascent < lh is the invariant
t.Helper()
lh = float64(EffectiveLineHeight())
// Long enough that the 1904-Dp test viewport overflows and scrolling is
// allowed (EditorLayout clamps ScrollOffset to LastLineY-based maxScroll;
// a short file would clamp the test scroll back to 0).
lines := 200
var buf strings.Builder
for i := 0; i < lines; i++ {
buf.WriteString("aa\n")
}
TheState = NewState()
TheState.Editor.Buffer = buf.String()
SetSelection(15, 17) // "aa" on line 5
gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()}
for i := 0; i < lines; i++ {
for c := 0; c < 2; c++ {
gl.ByteOffsets = append(gl.ByteOffsets, i*3+c)
gl.X = append(gl.X, ui.Dp(10+10*c))
gl.Y = append(gl.Y, ui.Dp(ascent+float64(i)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState.Editor.GlyphLayout = gl
// LastLineY is normally set by the shaper's layout feedback; emulate it.
TheState.LastLineY = ui.Dp(float64(lines) * lh)
TheState.Editor.IMEWindowStartByte = 0
TheState.ScrollOffset = 0
return lh
}
// TestSelectionMenu_FollowsTextAcrossScroll is a regression test for the
// selection menu staying at a FIXED SCREEN POSITION while the user scrolled:
// the menu must track the selected text (the menu is anchored to the text,
// not to where it was first shown). The menu's Y must move down/up by exactly
// the scroll delta while the anchor stays in view.
func TestSelectionMenu_FollowsTextAcrossScroll(t *testing.T) {
lh := menuTrackState(t)
// Lay out once so EditorRegion (the text region the menu is placed in)
// is established, exactly as in production.
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
showSelectionMenu()
e := &TheState.Editor
if !e.MenuVisible {
t.Fatal("menu not shown for a visible selection")
}
y0 := e.MenuRect.Y
// The menu sits ABOVE the anchor's line: lineTop(line 5) = reg.Y + 5*lh
// (reg.Y = 10+32, the anchor's visual line index is 5).
wantY0 := ui.Dp(42) + ui.Dp(5*lh) - menuH - 8
if !dpeq(y0, wantY0) {
t.Fatalf("initial menu Y = %v, want %v (above the anchor line)", y0, wantY0)
}
// Scroll down by exactly 2 lines and lay out again.
TheState.ScrollOffset = ui.Dp(2 * lh)
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
// The anchor moved up 2 lines on screen; the menu must follow by exactly
// the same amount.
want := wantY0 - ui.Dp(2*lh)
if got := e.MenuRect.Y; !dpeq(got, want) {
t.Fatalf("menu Y after 2-line scroll = %v, want %v (menu must track the text)", got, want)
}
if !e.MenuVisible {
t.Fatal("menu hidden while its anchor is still in view")
}
}
// TestSelectionMenu_ClampsWhenTextLeavesView checks the small-file edge: the
// buffer is the whole window, so the anchor never "leaves"; when the selected
// text scrolls off the top, the menu keeps its previous position clamped to
// the window (it never detaches into mid-screen stale territory and never
// vanishes while the selection can still be reached by scrolling back).
func TestSelectionMenu_ClampsWhenTextLeavesView(t *testing.T) {
lh := menuTrackState(t)
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
showSelectionMenu()
e := &TheState.Editor
if !e.MenuVisible {
t.Fatal("menu not shown")
}
yTop := e.MenuRect.Y // above line 5
// Scroll 10 lines: line 5 is 5 lines ABOVE the viewport top. Both ends
// of the selection stay inside the shaped window (small file = whole
// buffer) so the menu tracks: the preferred above-placement overflows
// (the line is off-screen top) and the menu flips below the line, which
// lands just under the window top (lineTop + lh + handleDropDp + 8).
TheState.ScrollOffset = ui.Dp(10 * lh)
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
if !e.MenuVisible {
t.Fatal("menu hidden, but the selection is still in the shaped window")
}
lineTop := ui.Dp(42) + ui.Dp(5*lh) - ui.Dp(10*lh) // line 5 after the scroll
want := lineTop + ui.Dp(lh) + ui.Dp(handleDropDp) + 8
if got := e.MenuRect.Y; !dpeq(got, want) {
t.Fatalf("menu Y = %v, want %v (tracked below the off-screen-top line, near the window top)", got, want)
}
_ = yTop
// Scroll far enough that even the below-placement overflows: the menu
// pins to the window top.
TheState.ScrollOffset = ui.Dp(60 * lh)
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
if !e.MenuVisible {
t.Fatal("menu hidden while the selection is still in the shaped window")
}
if got := e.MenuRect.Y; !dpeq(got, 8) {
t.Fatalf("menu Y = %v, want 8 (pinned to the window top)", got)
}
}
// TestSelectionMenu_FlipsBelowOnFirstLine checks the edge where there is no
// room above the anchor (selection on the first line): the menu must flip
// below the line rather than clamping to the window top and sitting over the
// anchor's own text.
func TestSelectionMenu_FlipsBelowOnFirstLine(t *testing.T) {
lh := menuTrackState(t)
TheState.Editor.Buffer = "aa\nbb\n"
SetSelection(0, 2) // "aa" on line 0
gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()}
const ascent float64 = 14
lines := 2
for i := 0; i < lines; i++ {
for c := 0; c < 2; c++ {
gl.ByteOffsets = append(gl.ByteOffsets, i*3+c)
gl.X = append(gl.X, ui.Dp(10+10*c))
gl.Y = append(gl.Y, ui.Dp(ascent+float64(i)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState.Editor.GlyphLayout = gl
TheState.LastLineY = ui.Dp(float64(lines) * lh)
TheState.Editor.IMEWindowStartByte = 0
TheState.ScrollOffset = 0
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
showSelectionMenu()
e := &TheState.Editor
if !e.MenuVisible {
t.Fatal("menu not shown")
}
// Line 0 top = reg.Y = 42; above would be 42-52-8 = -18 < 8, so the menu
// flips below the selection HANDLES: line bottom + handleDropDp + 8.
wantY := ui.Dp(42) + EffectiveLineHeight() + ui.Dp(handleDropDp) + 8
if !dpeq(e.MenuRect.Y, wantY) {
t.Fatalf("menu Y = %v, want %v (flipped below the first line, clear of the handles)", e.MenuRect.Y, wantY)
}
}
// TestSelectionMenu_AnchorsToStableEnd is a regression test for the menu
// floating inside a tall selection: the menu anchors to the selection's
// STABLE end (SelectionStart, the end that does not move while the END
// handle is dragged), so it must sit above the selection's TOP line even for
// a multi-line selection, and must not move when the end handle is dragged
// to a lower line.
func TestSelectionMenu_AnchorsToStableEnd(t *testing.T) {
lh := menuTrackState(t)
// 8 lines; selection from line 2 (stable start) to line 5 (moving end),
// so the menu has room ABOVE the top line (line 0 would flip below).
var buf strings.Builder
for i := 0; i < 8; i++ {
buf.WriteString("aaa\n")
}
TheState.Editor.Buffer = buf.String()
SetSelection(8, 18) // lines 2..4 (anchor at line 2's first byte)
gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()}
const ascent float64 = 14
for i := 0; i < 8; i++ {
for c := 0; c < 4; c++ {
gl.ByteOffsets = append(gl.ByteOffsets, i*4+c)
gl.X = append(gl.X, ui.Dp(10+10*c))
gl.Y = append(gl.Y, ui.Dp(ascent+float64(i)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState.Editor.GlyphLayout = gl
TheState.LastLineY = ui.Dp(8 * lh)
TheState.Editor.IMEWindowStartByte = 0
TheState.ScrollOffset = 0
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
showSelectionMenu()
e := &TheState.Editor
if !e.MenuVisible {
t.Fatal("menu not shown")
}
// Above line 2 (the stable start), NOT above line 4 (the moving end).
wantY := ui.Dp(42) + ui.Dp(2*lh) - menuH - 8
if !dpeq(e.MenuRect.Y, wantY) {
t.Fatalf("menu Y = %v, want %v (above the selection's TOP line)", e.MenuRect.Y, wantY)
}
// Drag the END handle down to line 7: the menu must stay put (still
// anchored to the stable start at line 2), not follow the end down.
SetSelection(8, 30) // lines 2..7
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
if !e.MenuVisible {
t.Fatal("menu hidden while the stable end is in view")
}
if got := e.MenuRect.Y; !dpeq(got, wantY) {
t.Fatalf("menu Y after end drag = %v, want %v (must not follow the moving end)", got, wantY)
}
}
// TestSelectionMenu_KeptVisibleWhileSelectionPartiallyOffScreen is a
// regression test for the menu disappearing permanently when the selection
// is extended to the top line while the menu was above it: with both ends
// outside the shaped window but the selection still intersecting the window,
// the menu keeps its previous position (clamped to the window) instead of
// hiding. (Small-file state: the whole buffer is the window, so this is
// emulated by a large file whose window is only a slice of the buffer.)
func TestSelectionMenu_KeptVisibleWhileSelectionPartiallyOffScreen(t *testing.T) {
lh := menuTrackState(t)
// Large buffer: 1000 "aa\n" lines. The shaped window is lines 2..40
// (bytes [6, 120)) — a slice in the middle of the buffer.
var buf strings.Builder
for i := 0; i < 1000; i++ {
buf.WriteString("aa\n")
}
TheState.Editor.Buffer = buf.String()
// Selection straddling the window: start byte 3 (line 1, ABOVE the
// window), end byte 180 (line 59, BELOW the window).
SetSelection(3, 180)
TheState.Editor.IMEWindowStartByte = 6
TheState.Editor.IMEWindowText = buf.String()[6:120]
gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()}
for i := 2; i < 40; i++ {
for c := 0; c < 2; c++ {
gl.ByteOffsets = append(gl.ByteOffsets, i*3+c)
gl.X = append(gl.X, ui.Dp(10+10*c))
gl.Y = append(gl.Y, ui.Dp(14+float64(i-2)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState.Editor.GlyphLayout = gl
TheState.Editor.MenuItems = []ui.MenuItem{{Label: "Copy"}}
// Establish window scale/region (positionSelectionMenu reads
// PixelWidth/PixelHeight via TheState.scale to clamp).
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
TheState.scale = 1
TheState.PixelWidth = 1000
TheState.PixelHeight = 2000
// EditorLayout re-shaped the small-file window; restore the hand-built
// slice window for the both-ends-!ok scenario.
TheState.Editor.IMEWindowStartByte = 6
TheState.Editor.IMEWindowText = buf.String()[6:120]
TheState.Editor.GlyphLayout = gl
e := &TheState.Editor
// Give the menu a previous (now stale, off-screen-top) position and
// check positionSelectionMenu keeps it, clamped, instead of reporting
// false (hide).
e.MenuRect = ui.Region{X: 100, Y: ui.Dp(-30), W: menuItemW, H: menuH}
if !positionSelectionMenu(e) {
t.Fatal("positionSelectionMenu reported hide while the selection still intersects the visible window")
}
if e.MenuRect.Y < 8 {
t.Fatalf("menu Y = %v, want clamped to >= 8 (kept at previous position, clamped)", e.MenuRect.Y)
}
// And it must still hide when the selection is entirely above the window.
SetSelection(0, 3) // line 0: entirely above window start 6
TheState.Editor.GlyphLayout = gl
if positionSelectionMenu(e) {
t.Fatal("positionSelectionMenu reported visible for a selection entirely outside the window")
}
}