Fix scroll slowdown at depth: bound the render window with word wrap

With word wrap on, VisibleByteRange mapped the viewport TOP from visual
to logical line space (WrapIndex.LineForVisual) but left the viewport
BOTTOM as the raw visual-line number and used it as a LOGICAL index.
The window therefore fetched (viewport lines + every wrapped line above
the viewport) of real text: an over-fetch that grew without bound with
scroll depth. Measured on a 2.9 MB file with wrap on: the shaped window
was ~3.7 KB at 300 KB depth but ~17.4 KB at 2.8 MB depth, costing
12 ms of shaping and 40 ms of frame draw per frame there — scrolling
visibly degraded the further down the file you got.

Map the bottom through LineForVisual exactly like the top: the logical
line containing the viewport's bottom visual line is the correct end,
and since each logical line yields at least one visual line the window
still always covers the viewport. The fetch is now viewport-bounded at
every depth (window flat at ~1 KB across 300 KB..2.8 MB in the test
file; deep frames drop from 40 ms to <4 ms).

Also drop the instrumentation added while diagnosing.
This commit is contained in:
Greg Pomerantz 2026-09-13 13:04:00 -04:00
parent f54ca2f81a
commit 36b6a86873

View File

@ -452,10 +452,12 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
// released). That is the root cause of the ~1GB "Unknown" memory on large // released). That is the root cause of the ~1GB "Unknown" memory on large
// files. // files.
// //
// Word wrap does not require a separate path: each real line produces at // Word wrap does not require a separate path: the viewport top and bottom
// least one visual line, so shaping viewportHeight/lineHeight real lines // are each expressed in VISUAL-line space and mapped to the logical lines
// always yields at least as many visual lines as fit in the viewport. The // that contain them (WrapIndex.LineForVisual). Because each logical line
// extra wrapped lines are simply clipped by the renderer. // produces at least one visual line, that logical range always covers the
// viewport; the fetch is viewport-bounded no matter how many wraps lie
// above it, and any spill past the bottom edge is clipped by the renderer.
lineH := lineHeight lineH := lineHeight
if lineH <= 0 { if lineH <= 0 {
lineH = EffectiveLineHeight() lineH = EffectiveLineHeight()
@ -564,10 +566,18 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei
// (the legacy no-wrap behavior). // (the legacy no-wrap behavior).
v0, _ := scrollDecompose(scrollOffset, lineHeight) v0, _ := scrollDecompose(scrollOffset, lineHeight)
startLine = int(v0) startLine = int(v0)
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
if w := cb.WrapIndex; w != nil { if w := cb.WrapIndex; w != nil {
startLine = w.LineForVisual(int32(v0)) startLine = w.LineForVisual(int32(v0))
// endLine is still in VISUAL space: it is the visual line at the
// viewport bottom, so map it to the logical line that contains it,
// exactly like the top. Using the raw visual number as a logical
// index over-fetched the window by every wrapped line above the
// viewport — an over-fetch that grows without bound with scroll
// depth, so shaping/drawing (and therefore scroll responsiveness)
// degraded the further down the file you were.
endLine = w.LineForVisual(int32(endLine))
} }
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
if startLine < 0 { if startLine < 0 {
startLine = 0 startLine = 0