Pad/internal/editor/selection_menu_track_test.go
Greg Pomerantz ef54486683 Make the editor top and bottom bars ~30% taller for bigger tap targets
The top bar's back/search icons (24dp in a 32dp bar) and the bottom
bar's wrap toggle (a text label in a 24dp bar) were fiddly to hit.
Top bar: 32 -> 42dp (new TopBarHeight const, which buildFindBar and
the editor text area already hang off); its icons 24 -> 31dp (new
TopBarIconSize; the find bar keeps the smaller ui.IconSize). Bottom
bar: BottomBarHeight 24 -> 31dp, labels re-centred.

While verifying, a relaunch into an app-unreadable saved directory
(/storage/emulated is media_rw:media_rw 0750 on device; only
/storage/emulated/0 is app-exposed) showed an empty browser: os.Stat
passes for such dirs, so the restore guard now probes with a real
os.ReadDir — the same operation the browser index does.

Test expectations that hard-coded the old 32dp top bar now use
TopBarHeight. Verified on emulator and phone: back/search/wrap all
tappable, find bar still docks under the taller top bar.
2026-09-03 12:34:18 -04:00

308 lines
12 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 = 32: the top bar is margin-free, the anchor's visual line
// index is 5).
wantY0 := TopBarHeight + 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 11 lines: line 5 is 6 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
// overflows the window top too (lineTop + lh + handleDropDp + 8 = 0 < 8)
// and clamps to it.
TheState.ScrollOffset = ui.Dp(11 * 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")
}
// Below-placement: lineTop(TopBarHeight) + 5*lh - 11*lh + lh + handleDropDp + 8
// = 0 < 8, so the menu clamps to the window top.
if got := e.MenuRect.Y; !dpeq(got, 8) {
t.Fatalf("menu Y = %v, want 8 (below-placement clamped to the window top)", got)
}
_ = 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 = TopBarHeight; above would be TopBarHeight-52-8 =
// -18 < 8, so the menu flips below the selection HANDLES: line bottom +
// handleDropDp + 8.
wantY := TopBarHeight + 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 3 (stable start) to line 5 (moving end),
// so the menu has room ABOVE the top line (a lower line would flip
// below now that the margin-free top bar leaves less headroom).
var buf strings.Builder
for i := 0; i < 8; i++ {
buf.WriteString("aaa\n")
}
TheState.Editor.Buffer = buf.String()
SetSelection(12, 22) // lines 3..5 (anchor at line 3'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 3 (the stable start), NOT above line 5 (the moving end).
wantY := TopBarHeight + ui.Dp(3*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 3), not follow the end down.
SetSelection(12, 30) // lines 3..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")
}
}