From e3214a34dda85b5ba26abb4fe2ea1b61c0168208 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 8 Jun 2026 09:55:58 +0200 Subject: [PATCH] Stop mapping stale-tail view lines to the wrong buffer line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track how many leading viewLines entries the most recent refresh built from the current buffer (freshViewLineCount) and bound the view-line→ buffer-line mapping on it. The entries past that count are the stale tail refreshViewLinesIfNeeded leaves in place for flicker-avoidance; they belong to a previous, longer render and must not be mapped. The old in-range guard was insufficient: with wrapping a stale entry's buffer index can still be in range of the shrunk buffer. Within the fresh range every entry was just built from the current buffer, so its index is guaranteed in range and the guard is no longer needed. This is the §8 correctness fix the identity-based escape restore depends on, since that read scans the view buffer while it is still loading. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/view.go | 28 ++++++++++++++++++---------- pkg/gocui/view_test.go | 3 --- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index cbfbfb8cb..02f22e980 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -74,6 +74,16 @@ type View struct { // true and viewLines to nil viewLines []viewLine + // Number of leading entries in viewLines that correspond to the current + // buffer (v.lines), as built by the most recent refreshViewLinesIfNeeded. + // refreshViewLinesIfNeeded overwrites viewLines in place without truncating, + // so when a re-render is shorter than the previous one the tail keeps stale + // entries from that previous render (drawn deliberately, to avoid flicker + // while the new content loads). Those entries don't correspond to the + // current buffer, so readers that map a view line to a buffer line must + // ignore them; this is the count of the entries they may trust. + freshViewLineCount int + // If the last character written was a newline, we don't write it but // instead set pendingNewline to true. If more text is written, we write the // newline then. This is to avoid having an extra blank at the end of the view. @@ -212,6 +222,7 @@ type pos struct { func (v *View) clearViewLines() { v.tainted = true v.viewLines = nil + v.freshViewLineCount = 0 v.clearHover() } @@ -1476,6 +1487,7 @@ func (v *View) refreshViewLinesIfNeeded() { } v.firstDirtyLine = len(lines) + v.freshViewLineCount = lineIdx v.tainted = false } @@ -1716,19 +1728,15 @@ func (v *View) BufferLineForViewLine(y int) (int, bool) { func (v *View) bufferLineForViewLine(y int) (int, bool) { v.refreshViewLinesIfNeeded() - if y < 0 || y >= len(v.viewLines) { + // Bound on freshViewLineCount rather than len(v.viewLines): the entries past + // it are a stale tail retained for flicker-avoidance (see freshViewLineCount) + // and don't correspond to the current buffer. Within the fresh range every + // entry's linesY was just built from v.lines, so it is guaranteed in range. + if y < 0 || y >= v.freshViewLineCount { return 0, false } - // refreshViewLinesIfNeeded overwrites viewLines in place without truncating, - // so while a shorter re-render is loading, the tail of viewLines can still - // hold stale entries pointing past the (shrunk) v.lines. Guard against that. - linesY := v.viewLines[y].linesY - if linesY >= len(v.lines) { - return 0, false - } - - return linesY, true + return v.viewLines[y].linesY, true } // indexFunc allows to split lines by words taking into account spaces diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 171517aea..2ed7e861f 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -230,10 +230,7 @@ func TestBufferLineForViewLineStaleTail(t *testing.T) { // buffer, so the mapping must fail. (On the buggy code it instead maps to // buffer line 1, the stale entry's lingering index.) _, ok = v.BufferLineForViewLine(4) - /* EXPECTED: assert.False(t, ok) - ACTUAL: */ - assert.True(t, ok) } func TestContainsColoredText(t *testing.T) {