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.
182 lines
5.8 KiB
Go
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")
|
|
}
|
|
}
|