From 36b6a86873e4fb3f34cd4e7fa869a713facc4b58 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 13 Sep 2026 13:04:00 -0400 Subject: [PATCH] Fix scroll slowdown at depth: bound the render window with word wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/editor/chunked_buffer.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 5b5e15e..e327483 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -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 // files. // - // Word wrap does not require a separate path: each real line produces at - // least one visual line, so shaping viewportHeight/lineHeight real lines - // always yields at least as many visual lines as fit in the viewport. The - // extra wrapped lines are simply clipped by the renderer. + // Word wrap does not require a separate path: the viewport top and bottom + // are each expressed in VISUAL-line space and mapped to the logical lines + // that contain them (WrapIndex.LineForVisual). Because each logical line + // 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 if lineH <= 0 { lineH = EffectiveLineHeight() @@ -564,10 +566,18 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei // (the legacy no-wrap behavior). v0, _ := scrollDecompose(scrollOffset, lineHeight) startLine = int(v0) + endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight))) if w := cb.WrapIndex; w != nil { 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 { startLine = 0