From a24196077d9da5ca19b7ce09e9edf460310974f4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 10 Jun 2026 07:40:38 +0200 Subject: [PATCH] Add gocui accessors for scanning a loading off-screen render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The escape restore (next commit) finds the row in a re-rendering focused main view that matches a target patch identity, and it has to do so while the content is still loading — i.e. against the off-screen buffer, before it is swapped in, since the displayed buffer still shows the previous render. Add the two primitives that scan needs: - OffscreenDiffLineContents exposes the per-line diff material (text, metadata, hyperlink) of the rows read so far into the off-screen render, so the resolver built last commit can run against the incoming content. - ViewLineForBufferLine maps a matched (unwrapped) buffer line back to the first view line that renders it — the inverse of BufferLineForViewLine — so the restore can scroll to and select that line once the render is swapped in. --- pkg/gocui/view.go | 35 +++++++++++++++++++++++++++++ pkg/gocui/view_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 4b0ff228d..1d31a8d20 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1856,6 +1856,23 @@ func (v *View) DiffLineContents() []DiffLineContent { return diffLineContents(v.buf) } +// OffscreenDiffLineContents returns the per-line diff material (see +// DiffLineContent) for the lines read so far into an in-progress off-screen +// re-render (see BeginOffscreenRender), or nil when no off-screen render is +// underway. While a focused main view re-renders, its displayed buffer still +// holds the previous render; this is how the escape restore scans the *incoming* +// content as it loads, to find the row matching a target patch identity and +// decide when it has read far enough to swap in and scroll there. +func (v *View) OffscreenDiffLineContents() []DiffLineContent { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if v.offscreen == nil { + return nil + } + return diffLineContents(v.offscreen) +} + func diffLineContents(buf *viewBuffer) []DiffLineContent { contents := make([]DiffLineContent, len(buf.lines)) for i, line := range buf.lines { @@ -1911,6 +1928,24 @@ func (v *View) bufferLineForViewLine(y int) (int, bool) { return linesY, true } +// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the +// first (wrapped) view line that renders it — the inverse of BufferLineForViewLine. +// The escape restore uses it to turn the buffer line it matched against a target +// patch identity into the view line to scroll to and select. Returns false if the +// buffer line isn't currently rendered into any view line. +func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + for i, vl := range v.viewLines { + if vl.linesY == bufferLineIdx { + return i, true + } + } + return 0, false +} + // indexFunc allows to split lines by words taking into account spaces // and 0. func indexFunc(r rune) bool { diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 3a49e4b0d..cfc92d267 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -258,6 +258,56 @@ func TestOffscreenRender(t *testing.T) { assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines()) } +// The escape restore scans the *incoming* content of a re-render as it loads, +// before it is swapped in, so it can find the row matching a target identity and +// decide when to swap. That means reading the off-screen buffer's loaded rows +// (text, metadata, hyperlink) while the displayed buffer still shows the old +// render. See View.offscreen / OffscreenDiffLineContents. +func TestOffscreenDiffLineContents(t *testing.T) { + v := NewView("name", 0, 0, 80, 10, OutputNormal) + + // No off-screen render in progress: nothing to scan. + assert.Nil(t, v.OffscreenDiffLineContents()) + + osc := func(payload string) string { return "\x1b]456;" + payload + "\x1b\\" } + v.BeginOffscreenRender() + v.writeString(strings.Join([]string{ + osc("1;c;1;;foo.txt") + "context", + osc("1;a;2;;foo.txt") + "added", + }, "\n")) + + contents := v.OffscreenDiffLineContents() + assert.Equal(t, []DiffLineContent{ + {Text: "context", Metadata: "1;c;1;;foo.txt"}, + {Text: "added", Metadata: "1;a;2;;foo.txt"}, + }, contents) + + // The displayed buffer is still empty; the scan reads the off-screen render. + assert.Empty(t, v.BufferLines()) +} + +// The escape restore matches a target identity against a buffer line, then needs +// the view line that renders it to scroll there and select it. ViewLineForBufferLine +// is that inverse mapping, and it must point at the *first* of the (wrapped) view +// lines a buffer line spans. +func TestViewLineForBufferLine(t *testing.T) { + v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9 + v.Wrap = true + + // Buffer line 0 is short (one view line); buffer line 1 wraps into three view + // lines (view lines 1, 2, 3); buffer line 2 is short again (view line 4). + v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast") + + for bufferLine, wantViewLine := range map[int]int{0: 0, 1: 1, 2: 4} { + viewLine, ok := v.ViewLineForBufferLine(bufferLine) + assert.True(t, ok) + assert.Equal(t, wantViewLine, viewLine) + } + + _, ok := v.ViewLineForBufferLine(3) + assert.False(t, ok) +} + // While an async re-render loads, it swaps in only a partially-filled buffer at // its first paint and keeps appending lines afterwards. The scrollbar must keep // using the pre-load height until the load ends, so the thumb doesn't shrink and