- Handles are now teardrops (stem + filled circle) like the native Android selector: 20dp circle normally, 28dp while dragging. - The grab region is a 48dp box around the circle centre, independent of the visual size; the old 16dp target was not grabbable by finger. - The copy/cut/paste menu is placed ABOVE the selected line (native behaviour), flipping below only when there is no room above. The menu is drawn last (top of the z-order) and Gio routes a touch to the topmost op whose clip contains it, so a below-placed menu covered the handles' grab boxes and silently stole every handle-drag press. - Regressions: menu above-placement arithmetic, first-line flip-below, and the existing tracking/clamp tests updated for the new policy. - On-device verified: handle drags work with the menu up (previously dead lower grab region), first-line handles still grabbable from the uncovered top strip, menu taps and tap-to-clear unchanged. - doc/development_plan.md section 15 records the z-order/placement contract.
160 lines
5.5 KiB
Go
160 lines
5.5 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+52, the anchor's visual line index is 5).
|
|
wantY0 := ui.Dp(62) + 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 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)
|
|
}
|
|
}
|
|
|
|
// 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 = 62; above would be 62-52-8 = 2 < 8, so the menu
|
|
// flips below: 62 + lh + 8.
|
|
wantY := ui.Dp(62) + EffectiveLineHeight() + 8
|
|
if !dpeq(e.MenuRect.Y, wantY) {
|
|
t.Fatalf("menu Y = %v, want %v (flipped below the first line)", e.MenuRect.Y, wantY)
|
|
}
|
|
}
|