Pad/internal/editor/selection_menu_track_test.go
Greg Pomerantz 49b60a79e8 Make editor top/bottom bars full-width; keep margin on the text area
The 10dp margin used to surround the top and bottom bars, leaving a gap
around them. The bars now span the full screen width and sit flush with
the top/bottom edges (merging with the system UI); their inner content
(icons, labels) keeps the margin so it still aligns with the text area.
The editor text area and the browser file list keep the margin, as before,
and the editor gains the 20dp of height the bar margins used to occupy.

Tests: menu-geometry expectations shifted with editorRegion.Y 42 -> 32;
the below-placement clamp test now expects the window-top clamp; the
stable-end test anchors at line 3 (less headroom above line 2 now).
2026-08-19 22:36:05 -04:00

306 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 = 32: the top bar is margin-free, the anchor's visual line
// index is 5).
wantY0 := ui.Dp(32) + 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")
}
// Below-placement: lineTop(32) + 5*lh - 10*lh + lh + handleDropDp + 8
// = 6.8 < 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 = 32; above would be 32-52-8 = -28 < 8, so the menu
// flips below the selection HANDLES: line bottom + handleDropDp + 8.
wantY := ui.Dp(32) + 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 := ui.Dp(32) + 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")
}
}