Pad/internal/editor/selection_menu_track_test.go
Greg Pomerantz dcb96d04d8 Fix Android window geometry, selection menu tracking
- SurfaceView windows are always translucent, so adjustResize cannot
  resize them: the keyboard overlapped the bottom of the window and the
  top bar sat under the status bar. Consume the insets in app instead:
  a smali-injected PadInsetsListener (identical block in build_emu.sh
  and build_phone.sh) shrinks the GioView to statusBarBottom..keyboardTop
  (or ..navBarTop) on every API 30+ insets dispatch, with
  setDecorFitsSystemWindows(false) so IME insets are delivered at
  targetSdk 34.
- Fix a change-detection bug in the listener: the height write skipped
  the topMargin/bottomMargin checks on every IME state transition, so
  the margins were never applied while the keyboard animated. All three
  params are now checked with a single requestLayout on any change.
- Selection menu now re-anchors to the selected word every frame while
  it is visible and hides when the word scrolls out of the viewport
  (positionSelectionMenu + regression test).
- Doc: development_plan.md section 14 (invariants, geometry contract,
  build-script identity invariant).

Verified on device: top bar below status bar and clickable, bottom bar
flush with keyboard top (view 128..1517 at 1080x2400), BACK dismiss +
re-tap re-raise without a show loop, typing saves, menu tracks scrolls.
2026-08-17 23:58:19 -04:00

120 lines
4.2 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 below the anchor's line: lineTop(line 5) = reg.Y + 5*lh
// (reg.Y = 10+52, the anchor's visual line index is 5).
wantY0 := ui.Dp(62) + ui.Dp(5*lh) + EffectiveLineHeight() + 8
if !dpeq(y0, wantY0) {
t.Fatalf("initial menu Y = %v, want %v (below 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 pins to the top of the window (it never
// detaches into mid-screen stale territory).
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")
}
// Scroll 10 lines: line 5 is 4 lines ABOVE the viewport top.
TheState.ScrollOffset = ui.Dp(10 * lh)
EditorLayout(ui.Dp(1000), ui.Dp(2000), false)
if !e.MenuVisible {
t.Fatal("menu hidden, but small-file layout can still track it (pinned)")
}
if got := e.MenuRect.Y; !dpeq(got, 8) {
t.Fatalf("menu Y = %v, want 8 (pinned to the window top when the text is above the viewport)", got)
}
}