Pad/internal/editor/find_scroll_test.go
Greg Pomerantz 587c1c5aad Fix find scroll position on long files
Two bugs made the search jump land in the wrong place:

1. Double-counted visual lines. The visual line at which logical line li
   starts is exactly VisualsBefore(li); the code computed li +
   VisualsBefore(li), i.e. 2x the intended depth with the all-ones
   pre-shape estimates. The MaxScroll clamp masked it on small files;
   long files landed far off. Now uses VisualsBefore(li) (fallback li).

2. Truncation vs non-integer line height. Line height is 16.8dp, so
   floor(V*lh) sits just above the target line's top and the window
   decomposition floors to the line above it. The target now rounds UP:
   ceil(V*lh) is always in [V*lh, (V+1)*lh), so the viewport top
   decomposes to exactly V. The line-height source now also prefers the
   shaped GlyphLayout.LineHeight like scrollVisualDecompose/MaxScroll.

3. Estimate settle. On long wrapped files the lines above the target are
   still estimated at 1 visual line when the jump happens, so the landing
   can be short. The scroll now arms a bounded settle (SettleByte /
   SettleScroll / SettlePasses on FindState); after each layout-feedback
   wrap-count correction the logic goroutine re-runs the target and
   re-scrolls until it converges, is exhausted (4 passes), or the user /
   the MaxScroll clamp moves the viewport (which cancels it). Edits,
   query changes, close, and file open disarm the settle.

Tests: round-trip scroll-target on a 2000-line wrapped file, the
no-wrap identity, settle correction/convergence, and settle cancellation
on user scroll.
2026-08-20 06:50:14 -04:00

182 lines
5.8 KiB
Go

package editor
import (
"math"
"math/rand"
"testing"
"pad/internal/ui"
)
// TestFindScrollTarget_PlacesMatchAtViewportTop is the regression test for
// "in a long file the viewport is not positioned properly when I search."
//
// The visual line at which logical line li starts is exactly V(li) =
// VisualsBefore(li) — the visual lines of all lines BEFORE it. The old code
// computed li + VisualsBefore(li), double-counting li: with the all-ones
// pre-shape estimates that is 2*li, so the viewport landed at twice the
// intended depth. On small files the MaxScroll clamp masked it (everything
// still fit / clamped to the bottom); on long files it was wildly wrong.
func TestFindScrollTarget_PlacesMatchAtViewportTop(t *testing.T) {
const n = 2000 // a "long" file
rng := rand.New(rand.NewSource(3))
counts := make([]int32, n)
for i := range counts {
counts[i] = int32(1 + rng.Intn(4)) // each line wraps into 1..4 visual lines
}
cb := newTestBufferForWrap(n, counts)
TheState = NewState()
TheState.Editor.ChunkedBuffer = cb
// Large MaxScroll so the clamp never interferes with the mapping itself.
totalVisuals := float64(cb.WrapIndex.TotalVisuals())
lh := float64(EffectiveLineHeight())
TheState.MaxScroll = ui.Dp(totalVisuals * lh)
w := cb.WrapIndex
// Every line i is "x\n" (2 bytes) except the last ("x"), so line i starts
// at byte i*2.
for _, li := range []int{0, 1, 137, 1500, n - 2, n - 1} {
absByte := li * 2
target, ok := findScrollTarget(absByte)
if !ok {
t.Fatalf("line %d: findScrollTarget ok=false", li)
}
want := ui.Dp(math.Ceil(float64(w.VisualsBefore(li)) * lh))
if target != want {
t.Fatalf("line %d: target = %v, want ceil(V(%d)*lh) = %v (the visual line at which the line starts)",
li, target, li, want)
}
// The inverse mapping must put the viewport top back on logical line li.
TheState.ScrollOffset = target
k, _ := scrollVisualDecompose()
if k != li {
t.Fatalf("line %d: decompose(target) = %d, want %d (round-trip failed)", li, k, li)
}
// Guard against the old double-count: the buggy value was
// (li + V(li))*lh, which for li > 0 is never the correct target.
if li > 0 {
if buggy := ui.Dp(math.Ceil(float64(int64(li)+int64(w.VisualsBefore(li))) * lh)); target == buggy {
t.Fatalf("line %d: target %v equals the old double-count value", li, target)
}
}
}
}
// TestFindScrollTarget_NoWrapIsIdentity pins the all-ones (pre-shape /
// word-wrap-off) case: the target is exactly li*lineHeight, matching the
// legacy mapping and the nil-WrapIndex fallback.
func TestFindScrollTarget_NoWrapIsIdentity(t *testing.T) {
const n = 500
counts := make([]int32, n)
for i := range counts {
counts[i] = 1
}
cb := newTestBufferForWrap(n, counts)
TheState = NewState()
TheState.Editor.ChunkedBuffer = cb
lh := float64(EffectiveLineHeight())
TheState.MaxScroll = ui.Dp(float64(n) * lh)
for _, li := range []int{0, 1, 42, n - 1} {
target, ok := findScrollTarget(li * 2)
if !ok {
t.Fatalf("line %d: ok=false", li)
}
if want := ui.Dp(math.Ceil(float64(li) * lh)); target != want {
t.Fatalf("line %d: target = %v, want ceil(li*lh) = %v", li, target, want)
}
}
}
// TestFindSettle_CorrectsAfterWrapCounts verifies the post-jump correction:
// the search scroll arms a settle with the estimate-based offset; when the
// (simulated) shaping pass corrects the wrap counts, findSettle re-scrolls
// to the accurate position and then converges.
func TestFindSettle_CorrectsAfterWrapCounts(t *testing.T) {
const n = 1000
counts := make([]int32, n)
for i := range counts {
counts[i] = 1 // pre-shape estimates
}
cb := newTestBufferForWrap(n, counts)
TheState = NewState()
TheState.Editor.ChunkedBuffer = cb
lh := float64(EffectiveLineHeight())
TheState.MaxScroll = ui.Dp(float64(4*n) * lh)
const li = 500
absByte := li * 2
e := &TheState.Editor
e.Find = FindState{Visible: true}
target, ok := findScrollTarget(absByte)
if !ok {
t.Fatal("findScrollTarget ok=false")
}
TheState.ScrollOffset = target
e.Find.SettleByte = absByte
e.Find.SettleScroll = target
e.Find.SettlePasses = 4
// Simulate the shaping pass: every line above the target wraps into 3
// visual lines. The estimate-based offset is now far too small.
for i := 0; i < li; i++ {
cb.WrapIndex.Set(i, 3)
}
if !e.findSettle() {
t.Fatal("findSettle did not re-scroll after the wrap-count correction")
}
want := ui.Dp(math.Ceil(float64(cb.WrapIndex.VisualsBefore(li)) * lh))
if TheState.ScrollOffset != want {
t.Fatalf("settled offset %v, want %v", TheState.ScrollOffset, want)
}
if k, _ := scrollVisualDecompose(); k != li {
t.Fatalf("decompose(settled) = %d, want %d", k, li)
}
if e.Find.SettlePasses != 3 {
t.Fatalf("passes %d, want 3", e.Find.SettlePasses)
}
// No further correction: the settle converges and disarms.
if e.findSettle() {
t.Fatal("findSettle did not converge on a stable offset")
}
if e.Find.SettleByte != -1 {
t.Fatalf("SettleByte %d, want -1 after convergence", e.Find.SettleByte)
}
}
// TestFindSettle_CancelsOnUserScroll: if anything other than the settle
// moves the viewport, the settle gives up (no yanking the view back).
func TestFindSettle_CancelsOnUserScroll(t *testing.T) {
const n = 200
counts := make([]int32, n)
for i := range counts {
counts[i] = 1
}
cb := newTestBufferForWrap(n, counts)
TheState = NewState()
TheState.Editor.ChunkedBuffer = cb
lh := float64(EffectiveLineHeight())
TheState.MaxScroll = ui.Dp(float64(n) * lh)
const li = 100
e := &TheState.Editor
e.Find = FindState{Visible: true}
target, _ := findScrollTarget(li * 2)
TheState.ScrollOffset = target
e.Find.SettleByte = li * 2
e.Find.SettleScroll = target
e.Find.SettlePasses = 4
// The user scrolls away.
TheState.ScrollOffset = target + ui.Dp(5*lh)
if e.findSettle() {
t.Fatal("findSettle re-scrolled after a user scroll")
}
if e.Find.SettleByte != -1 {
t.Fatal("settle not cancelled by a user scroll")
}
}