Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 125 additions & 11 deletions internal/vterm/alt_screen_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ func (v *VTerm) captureScreenToScrollback() {
}

oldViewOffset := v.ViewOffset
if v.matchesTrackedAltScreenCapture(lines) {
if matched, dedupRemoved := v.matchesTrackedAltScreenCapture(lines); matched {
if oldViewOffset > 0 {
v.ViewOffset = oldViewOffset - dedupRemoved
if v.ViewOffset < 0 {
v.ViewOffset = 0
}
if v.ViewOffset > len(v.Scrollback) {
v.ViewOffset = len(v.Scrollback)
}
}
return
}
removed, dropped := v.dropTrackedAltScreenCapture()
Expand All @@ -39,8 +48,11 @@ func (v *VTerm) captureScreenToScrollback() {
return
}

// Partial overlap detection — skip lines already in scrollback from scrollUp
overlap := scrollbackTailOverlap(v.Scrollback, lines)

added := 0
for _, line := range lines {
for _, line := range lines[overlap:] {
v.Scrollback = append(v.Scrollback, CopyLine(line))
added++
}
Expand Down Expand Up @@ -109,48 +121,127 @@ func isVisiblyBlankLine(line []Cell) bool {
return true
}

func (v *VTerm) matchesTrackedAltScreenCapture(lines [][]Cell) bool {
// matchesTrackedAltScreenCapture checks if lines match the previously captured
// alt-screen content. The capture may no longer be at the scrollback tail if
// scrollUp has appended lines after it (tracked by altScreenCaptureEndOffset).
func (v *VTerm) matchesTrackedAltScreenCapture(lines [][]Cell) (bool, int) {
if v.altScreenCaptureLen <= 0 || !v.altScreenCaptureTracked || v.altScreenCaptureLen != len(lines) {
return false
return false, 0
}
total := v.altScreenCaptureLen + v.altScreenCaptureEndOffset
sb := len(v.Scrollback)
if sb < v.altScreenCaptureLen {
if sb < total {
v.altScreenCaptureLen = 0
v.altScreenCaptureTracked = false
return false
v.altScreenCaptureEndOffset = 0
return false, 0
}
captureStart := sb - total
for i := 0; i < v.altScreenCaptureLen; i++ {
if !linesEqual(v.Scrollback[captureStart+i], lines[i]) {
return false, 0
}
}
return matchesScrollbackTail(v.Scrollback, lines)

// Match confirmed — dedup scrollUp trailing lines that duplicate
// content already present in the pre-capture scrollback.
removed := v.dedupScrollUpTrailing(captureStart)
return true, removed
}

// dropTrackedAltScreenCapture removes the previously captured alt-screen
// content from scrollback. With altScreenCaptureEndOffset, the capture may
// be in the middle of scrollback (not at the tail), so we remove from its
// tracked position and preserve trailing scrollUp lines.
func (v *VTerm) dropTrackedAltScreenCapture() (int, [][]Cell) {
if v.altScreenCaptureLen <= 0 || !v.altScreenCaptureTracked {
if v.altScreenCaptureLen <= 0 {
v.altScreenCaptureEndOffset = 0
return 0, nil
}
v.altScreenCaptureLen = 0
v.altScreenCaptureEndOffset = 0
return 0, nil
}
if len(v.Scrollback) < v.altScreenCaptureLen {
total := v.altScreenCaptureLen + v.altScreenCaptureEndOffset
if len(v.Scrollback) < total {
v.altScreenCaptureLen = 0
v.altScreenCaptureTracked = false
v.altScreenCaptureEndOffset = 0
return 0, nil
}
start := len(v.Scrollback) - v.altScreenCaptureLen
captureStart := len(v.Scrollback) - total
captureEnd := captureStart + v.altScreenCaptureLen

// Copy the removed rows so the returned slice doesn't alias the
// Scrollback backing array — a subsequent append could overwrite it.
src := v.Scrollback[start:]
src := v.Scrollback[captureStart:captureEnd]
removedRows := make([][]Cell, len(src))
copy(removedRows, src)
removed := v.altScreenCaptureLen
v.Scrollback = v.Scrollback[:len(v.Scrollback)-removed]

// Remove capture from its position (preserving trailing scrollUp lines)
v.Scrollback = append(v.Scrollback[:captureStart], v.Scrollback[captureEnd:]...)
v.altScreenCaptureLen = 0
v.altScreenCaptureTracked = false

// Dedup scrollUp trailing lines against pre-capture scrollback.
// After removal, trailing lines are at [captureStart, captureStart+endOffset).
dedupRemoved := v.dedupScrollUpTrailing(captureStart)
removed += dedupRemoved
v.altScreenCaptureEndOffset = 0

return removed, removedRows
}

// dedupScrollUpTrailing removes scrollUp lines from the scrollback that
// duplicate content already present in the pre-capture scrollback region
// (scrollback[:preCaptureLen]). This prevents duplication when TUI redraws
// cause the same content to scroll off multiple times across erase cycles.
//
// Known limitation: only compares trailing lines against pre-capture content.
// When above-fold content changes across cycles but below-fold stays the same,
// trailing lines accumulate without dedup (e.g. [X,Y] -> [X,Y,X,Y] -> ...).
// This is bounded by MaxScrollback and only affects edge cases where the top
// portion of a TUI changes while the bottom stays identical. Fixing would
// require tracking new-vs-old trailing lines to detect internal repetition.
func (v *VTerm) dedupScrollUpTrailing(preCaptureLen int) int {
trailing := v.altScreenCaptureEndOffset
if trailing <= 0 {
v.altScreenCaptureEndOffset = 0
return 0
}

if preCaptureLen <= 0 || preCaptureLen > len(v.Scrollback) {
return 0
}

before := v.Scrollback[:preCaptureLen]
trailingStart := len(v.Scrollback) - trailing
if trailingStart < preCaptureLen {
trailingStart = preCaptureLen
}
if trailingStart >= len(v.Scrollback) {
return 0
}

trailingLines := v.Scrollback[trailingStart:]

overlap := scrollbackTailOverlap(before, trailingLines)
if overlap <= 0 {
return 0
}

// Remove the overlapping prefix from the trailing lines
v.Scrollback = append(v.Scrollback[:trailingStart], v.Scrollback[trailingStart+overlap:]...)
v.altScreenCaptureEndOffset = trailing - overlap
return overlap
}

func (v *VTerm) invalidateAltScreenCapture() {
v.altScreenCaptureLen = 0
v.altScreenCaptureTracked = false
v.altScreenCaptureEndOffset = 0
}

// captureRowsMatch compares lines with captured rows using the current terminal width.
Expand Down Expand Up @@ -182,6 +273,29 @@ func matchesScrollbackTail(scrollback, lines [][]Cell) bool {
return true
}

// scrollbackTailOverlap returns the length of the longest suffix of scrollback
// that matches a prefix of lines. This detects lines already pushed into
// scrollback by scrollUp so captureScreenToScrollback can skip them.
func scrollbackTailOverlap(scrollback, lines [][]Cell) int {
maxK := len(lines)
if len(scrollback) < maxK {
maxK = len(scrollback)
}
for k := maxK; k > 0; k-- {
match := true
for i := 0; i < k; i++ {
if !linesEqual(scrollback[len(scrollback)-k+i], lines[i]) {
match = false
break
}
}
if match {
return k
}
}
return 0
}

// linesEqual returns true if two cell slices have identical runes and styles.
func linesEqual(a, b []Cell) bool {
if len(a) != len(b) {
Expand Down
97 changes: 96 additions & 1 deletion internal/vterm/alt_screen_erase_capture_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package vterm

import "testing"
import (
"testing"
)

func TestAltScreenEraseCapturesScrollback(t *testing.T) {
vt := New(10, 3)
Expand Down Expand Up @@ -183,6 +185,24 @@ func TestAltScreenEraseReplacementPreservesViewOffset(t *testing.T) {
}
}

func TestAltScreenEraseTrackedMatchDedupsViewOffset(t *testing.T) {
vt := New(10, 4)
vt.AllowAltScreenScrollback = true
vt.Write([]byte("\x1b[?1049h"))

vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06"))
vt.Write([]byte("\x1b[2J"))
vt.ViewOffset = 2

vt.Write([]byte("\x1b[H"))
vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06"))
vt.Write([]byte("\x1b[2J"))

if vt.ViewOffset != 2 {
t.Fatalf("expected ViewOffset to remain anchored at 2 after matched redraw dedup, got %d", vt.ViewOffset)
}
}

func TestAltScreenEraseTrimsLeadingBlankRows(t *testing.T) {
vt := New(10, 6)
vt.AllowAltScreenScrollback = true
Expand Down Expand Up @@ -310,3 +330,78 @@ func TestAltScreenErasePreservesStyledSpaceRows(t *testing.T) {
t.Fatalf("expected captured row to preserve styled background, got %+v", got)
}
}

// lineText extracts a string from a []Cell row, trimming trailing spaces.
func lineText(line []Cell) string {
var s []rune
for _, c := range line {
if c.Rune == 0 {
s = append(s, ' ')
} else {
s = append(s, c.Rune)
}
}
for len(s) > 0 && s[len(s)-1] == ' ' {
s = s[:len(s)-1]
}
return string(s)
}

func dumpScrollback(t *testing.T, vt *VTerm) {
t.Helper()
for i, line := range vt.Scrollback {
t.Logf(" scrollback[%d] = %q", i, lineText(line))
}
}

func TestAltScreenEraseDedupsScrollUpOverlap(t *testing.T) {
// Simulate content that overflows a 4-row terminal:
// 6 lines of content → first 2 scroll off via scrollUp,
// last 4 remain on screen. After erase+redraw cycle(s),
// scrollback should not accumulate duplicates.
vt := New(10, 4)
vt.AllowAltScreenScrollback = true
vt.Write([]byte("\x1b[?1049h"))

// First draw: 6 lines on a 4-row screen
vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06"))
// scrollUp pushed line01, line02 into scrollback
vt.Write([]byte("\x1b[2J")) // erase captures line03-line06

sbAfterFirst := len(vt.Scrollback)

// Redraw from cursor home (simulating TUI repaint)
vt.Write([]byte("\x1b[H"))
vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06"))
vt.Write([]byte("\x1b[2J"))

sbAfterSecond := len(vt.Scrollback)

if sbAfterSecond != sbAfterFirst {
t.Errorf("scrollback should stay stable across redraws: first=%d, second=%d",
sbAfterFirst, sbAfterSecond)
dumpScrollback(t, vt)
}

// Third cycle
vt.Write([]byte("\x1b[H"))
vt.Write([]byte("line01\r\nline02\r\nline03\r\nline04\r\nline05\r\nline06"))
vt.Write([]byte("\x1b[2J"))

if len(vt.Scrollback) != sbAfterFirst {
t.Errorf("scrollback should remain stable after 3rd redraw: expected=%d, got=%d",
sbAfterFirst, len(vt.Scrollback))
dumpScrollback(t, vt)
}

// Verify no adjacent duplicate lines
for i := 1; i < len(vt.Scrollback); i++ {
prev := lineText(vt.Scrollback[i-1])
cur := lineText(vt.Scrollback[i])
if prev == cur && prev != "" {
t.Errorf("duplicate adjacent scrollback at index %d: %q", i, cur)
dumpScrollback(t, vt)
break
}
}
}
Loading
Loading