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+32, the anchor's visual line index is 5). wantY0 := ui.Dp(42) + 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") } lineTop := ui.Dp(42) + ui.Dp(5*lh) - ui.Dp(10*lh) // line 5 after the scroll want := lineTop + ui.Dp(lh) + ui.Dp(handleDropDp) + 8 if got := e.MenuRect.Y; !dpeq(got, want) { t.Fatalf("menu Y = %v, want %v (tracked below the off-screen-top line, near the window top)", got, want) } _ = 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 = 42; above would be 42-52-8 = -18 < 8, so the menu // flips below the selection HANDLES: line bottom + handleDropDp + 8. wantY := ui.Dp(42) + 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 2 (stable start) to line 5 (moving end), // so the menu has room ABOVE the top line (line 0 would flip below). var buf strings.Builder for i := 0; i < 8; i++ { buf.WriteString("aaa\n") } TheState.Editor.Buffer = buf.String() SetSelection(8, 18) // lines 2..4 (anchor at line 2'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 2 (the stable start), NOT above line 4 (the moving end). wantY := ui.Dp(42) + ui.Dp(2*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 2), not follow the end down. SetSelection(8, 30) // lines 2..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") } }