Add gocui accessors for scanning a loading off-screen render

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.
This commit is contained in:
Stefan Haller 2026-06-10 07:40:38 +02:00
parent cfe356b08d
commit a24196077d
2 changed files with 85 additions and 0 deletions

View file

@ -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 {

View file

@ -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