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.
308 lines
12 KiB
Go
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")
|
|
}
|
|
}
|