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) } }