From a3a28bdbbf509d10ca1be6437a140ae37f0ad3e4 Mon Sep 17 00:00:00 2001 From: Gaurav Gosain Date: Wed, 22 Oct 2025 14:24:40 +0400 Subject: [PATCH] feat(vt): implement scrollback buffer support Adds comprehensive scrollback buffer functionality to the VT terminal emulator, allowing applications to store and access lines that have scrolled off the top of the visible screen. Features: - Scrollback buffer with configurable maximum lines (default 10,000) - Automatic line capture during full-width scrolling operations - Thread-safe access with read/write mutexes - Deep copying of lines to prevent aliasing issues - ED 3 (ESC[3J) sequence support for clearing scrollback - Separate scrollback for main and alternate screens - Comprehensive API for accessing and managing scrollback API: - Scrollback() - Get scrollback buffer reference - ScrollbackLen() - Get number of lines in scrollback - ScrollbackLine(index) - Retrieve line at index - ClearScrollback() - Clear all scrollback history - SetScrollbackMaxLines(n) - Configure buffer size The scrollback only captures full-width scrolling from Y=0, not limited scroll regions, following standard terminal emulator behavior. --- vt/emulator.go | 27 ++++ vt/handlers.go | 7 +- vt/screen.go | 77 ++++++++++- vt/scrollback.go | 105 +++++++++++++++ vt/scrollback_test.go | 306 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 vt/scrollback.go create mode 100644 vt/scrollback_test.go diff --git a/vt/emulator.go b/vt/emulator.go index 0276fd7f..18acdad4 100644 --- a/vt/emulator.go +++ b/vt/emulator.go @@ -152,6 +152,33 @@ func (e *Emulator) SetCell(x, y int, c *uv.Cell) { e.scr.SetCell(x, y, c) } +// Scrollback returns the scrollback buffer of the main screen. +// Note: The alternate screen does not maintain scrollback. +func (e *Emulator) Scrollback() *Scrollback { + return e.scrs[0].Scrollback() +} + +// ClearScrollback clears the scrollback buffer of the main screen. +func (e *Emulator) ClearScrollback() { + e.scrs[0].ClearScrollback() +} + +// ScrollbackLen returns the number of lines in the scrollback buffer. +func (e *Emulator) ScrollbackLen() int { + return e.scrs[0].ScrollbackLen() +} + +// ScrollbackLine returns a line from the scrollback buffer at the given index. +// Index 0 is the oldest line. Returns nil if index is out of bounds. +func (e *Emulator) ScrollbackLine(index int) []uv.Cell { + return e.scrs[0].ScrollbackLine(index) +} + +// SetScrollbackMaxLines sets the maximum number of lines for the scrollback buffer. +func (e *Emulator) SetScrollbackMaxLines(maxLines int) { + e.scrs[0].SetScrollbackMaxLines(maxLines) +} + // WidthMethod returns the width method used by the terminal. func (e *Emulator) WidthMethod() uv.WidthMethod { if e.isModeSet(ansi.UnicodeCoreMode) { diff --git a/vt/handlers.go b/vt/handlers.go index dff2b40e..457e3e95 100644 --- a/vt/handlers.go +++ b/vt/handlers.go @@ -555,10 +555,9 @@ func (e *Emulator) registerDefaultCsiHandlers() { rect := uv.Rect(0, 0, width, y+1) e.scr.FillArea(e.scr.blankCell(), rect) case 2: // erase screen - fallthrough - case 3: // erase display - //nolint:godox - // TODO: Scrollback buffer support? + e.scr.Clear() + case 3: // erase display including scrollback + e.scr.ClearScrollback() e.scr.Clear() default: return false diff --git a/vt/screen.go b/vt/screen.go index d0a0b391..a7b90bab 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -1,6 +1,8 @@ package vt import ( + "sync" + uv "github.com/charmbracelet/ultraviolet" ) @@ -14,11 +16,16 @@ type Screen struct { cur, saved Cursor // scroll is the scroll region. scroll uv.Rectangle + // scrollback is the scrollback buffer for lines that have scrolled off the top. + scrollback *Scrollback + // mutex for the screen. + mu sync.RWMutex } // NewScreen creates a new screen. func NewScreen(w, h int) *Screen { s := Screen{} + s.scrollback = NewScrollback(0) // Use default size s.Resize(w, h) return &s } @@ -256,9 +263,31 @@ func (s *Screen) DeleteCell(n int) { } // ScrollUp scrolls the content up n lines within the given region. Lines -// scrolled past the top margin are lost. This is equivalent to [ansi.SU] which -// moves the cursor to the top margin and performs a [ansi.DL] operation. +// scrolled past the top margin are saved to the scrollback buffer if the +// scroll region encompasses the full screen width and starts at the top. +// This is equivalent to [ansi.SU] which moves the cursor to the top margin +// and performs a [ansi.DL] operation. func (s *Screen) ScrollUp(n int) { + if n <= 0 { + return + } + + s.mu.Lock() + scroll := s.scroll + width := s.buf.Width() + + // Only save to scrollback if we're scrolling the main screen area + // (not a limited scroll region) and the scroll region starts at Y=0 + if scroll.Min.Y == 0 && scroll.Min.X == 0 && scroll.Dx() == width { + // Save the top n lines to scrollback before they're deleted + for i := 0; i < n && i < scroll.Dy(); i++ { + y := scroll.Min.Y + i + line := extractLine(&s.buf, y, width) + s.scrollback.PushLine(line) + } + } + s.mu.Unlock() + x, y := s.CursorPosition() s.setCursor(s.cur.X, 0, true) s.DeleteLine(n) @@ -332,3 +361,47 @@ func (s *Screen) blankCell() *uv.Cell { c.Style.Bg = s.cur.Pen.Bg return &c } + +// Scrollback returns the scrollback buffer for this screen. +func (s *Screen) Scrollback() *Scrollback { + return s.scrollback +} + +// ClearScrollback clears all lines from the scrollback buffer. +func (s *Screen) ClearScrollback() { + s.mu.Lock() + defer s.mu.Unlock() + if s.scrollback != nil { + s.scrollback.Clear() + } +} + +// ScrollbackLen returns the number of lines currently in the scrollback buffer. +func (s *Screen) ScrollbackLen() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.scrollback == nil { + return 0 + } + return s.scrollback.Len() +} + +// ScrollbackLine returns the line at the specified index in the scrollback buffer. +// Index 0 is the oldest line. Returns nil if the index is out of bounds. +func (s *Screen) ScrollbackLine(index int) []uv.Cell { + s.mu.RLock() + defer s.mu.RUnlock() + if s.scrollback == nil { + return nil + } + return s.scrollback.Line(index) +} + +// SetScrollbackMaxLines sets the maximum number of lines for the scrollback buffer. +func (s *Screen) SetScrollbackMaxLines(maxLines int) { + s.mu.Lock() + defer s.mu.Unlock() + if s.scrollback != nil { + s.scrollback.SetMaxLines(maxLines) + } +} diff --git a/vt/scrollback.go b/vt/scrollback.go new file mode 100644 index 00000000..d748181e --- /dev/null +++ b/vt/scrollback.go @@ -0,0 +1,105 @@ +package vt + +import ( + uv "github.com/charmbracelet/ultraviolet" +) + +// Scrollback represents a scrollback buffer that stores lines that have +// scrolled off the top of the visible screen. +type Scrollback struct { + // lines stores the scrollback lines, with the oldest at index 0 + lines [][]uv.Cell + // maxLines is the maximum number of lines to keep in scrollback + maxLines int +} + +// NewScrollback creates a new scrollback buffer with the specified maximum +// number of lines. If maxLines is 0, a default of 10000 lines is used. +func NewScrollback(maxLines int) *Scrollback { + if maxLines <= 0 { + maxLines = 10000 // Default scrollback size + } + return &Scrollback{ + lines: make([][]uv.Cell, 0, min(maxLines, 1000)), // Pre-allocate reasonable amount + maxLines: maxLines, + } +} + +// PushLine adds a line to the scrollback buffer. If the buffer is full, +// the oldest line is removed. +func (sb *Scrollback) PushLine(line []uv.Cell) { + if len(line) == 0 { + return + } + + // Make a copy of the line to avoid aliasing issues + lineCopy := make([]uv.Cell, len(line)) + copy(lineCopy, line) + + // If we're at capacity, remove the oldest line + if len(sb.lines) >= sb.maxLines { + sb.lines = sb.lines[1:] + } + + sb.lines = append(sb.lines, lineCopy) +} + +// Len returns the number of lines currently in the scrollback buffer. +func (sb *Scrollback) Len() int { + return len(sb.lines) +} + +// Line returns the line at the specified index in the scrollback buffer. +// Index 0 is the oldest line, and Len()-1 is the newest (most recently scrolled). +// Returns nil if the index is out of bounds. +func (sb *Scrollback) Line(index int) []uv.Cell { + if index < 0 || index >= len(sb.lines) { + return nil + } + return sb.lines[index] +} + +// Lines returns a slice of all lines in the scrollback buffer, from oldest +// to newest. The returned slice should not be modified. +func (sb *Scrollback) Lines() [][]uv.Cell { + return sb.lines +} + +// Clear removes all lines from the scrollback buffer. +func (sb *Scrollback) Clear() { + sb.lines = sb.lines[:0] // Keep capacity, just reset length +} + +// MaxLines returns the maximum number of lines this scrollback can hold. +func (sb *Scrollback) MaxLines() int { + return sb.maxLines +} + +// SetMaxLines sets the maximum number of lines for the scrollback buffer. +// If the new limit is smaller than the current number of lines, older lines +// are discarded to fit the new limit. +func (sb *Scrollback) SetMaxLines(maxLines int) { + if maxLines <= 0 { + maxLines = 10000 // Default scrollback size + } + sb.maxLines = maxLines + + // If we have too many lines, trim from the front (oldest) + if len(sb.lines) > maxLines { + sb.lines = sb.lines[len(sb.lines)-maxLines:] + } +} + +// extractLine extracts a complete line from the buffer at the given Y coordinate. +// This is a helper function to copy cells from a buffer line. +func extractLine(buf *uv.Buffer, y, width int) []uv.Cell { + line := make([]uv.Cell, width) + for x := 0; x < width; x++ { + if cell := buf.CellAt(x, y); cell != nil { + line[x] = *cell + } else { + line[x] = uv.EmptyCell + } + } + return line +} diff --git a/vt/scrollback_test.go b/vt/scrollback_test.go new file mode 100644 index 00000000..ba2fdc3b --- /dev/null +++ b/vt/scrollback_test.go @@ -0,0 +1,306 @@ +package vt + +import ( + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +func TestScrollback_Basic(t *testing.T) { + sb := NewScrollback(100) + + if sb.Len() != 0 { + t.Errorf("new scrollback should be empty, got %d lines", sb.Len()) + } + + if sb.MaxLines() != 100 { + t.Errorf("expected max lines 100, got %d", sb.MaxLines()) + } + + // Add a line + line := []uv.Cell{ + {Content: "H", Width: 1}, + {Content: "i", Width: 1}, + } + sb.PushLine(line) + + if sb.Len() != 1 { + t.Errorf("expected 1 line after push, got %d", sb.Len()) + } + + // Retrieve the line + retrieved := sb.Line(0) + if len(retrieved) != 2 { + t.Errorf("expected line length 2, got %d", len(retrieved)) + } + if retrieved[0].Content != "H" || retrieved[1].Content != "i" { + t.Errorf("line content mismatch") + } +} + +func TestScrollback_Overflow(t *testing.T) { + sb := NewScrollback(3) // Small buffer + + // Add 5 lines + for i := 0; i < 5; i++ { + line := []uv.Cell{{Content: string(rune('A' + i)), Width: 1}} + sb.PushLine(line) + } + + // Should only have 3 lines (newest) + if sb.Len() != 3 { + t.Errorf("expected 3 lines after overflow, got %d", sb.Len()) + } + + // Oldest should be 'C' (lines A and B were dropped) + if sb.Line(0)[0].Content != "C" { + t.Errorf("expected oldest line 'C', got %s", sb.Line(0)[0].Content) + } + + // Newest should be 'E' + if sb.Line(2)[0].Content != "E" { + t.Errorf("expected newest line 'E', got %s", sb.Line(2)[0].Content) + } +} + +func TestScrollback_Clear(t *testing.T) { + sb := NewScrollback(100) + + for i := 0; i < 10; i++ { + line := []uv.Cell{{Content: string(rune('A' + i)), Width: 1}} + sb.PushLine(line) + } + + if sb.Len() != 10 { + t.Errorf("expected 10 lines, got %d", sb.Len()) + } + + sb.Clear() + + if sb.Len() != 0 { + t.Errorf("expected 0 lines after clear, got %d", sb.Len()) + } +} + +func TestScrollback_SetMaxLines(t *testing.T) { + sb := NewScrollback(100) + + // Add 10 lines + for i := 0; i < 10; i++ { + line := []uv.Cell{{Content: string(rune('A' + i)), Width: 1}} + sb.PushLine(line) + } + + // Reduce max to 5 + sb.SetMaxLines(5) + + if sb.Len() != 5 { + t.Errorf("expected 5 lines after reducing max, got %d", sb.Len()) + } + + // Should keep newest 5 lines (F-J) + if sb.Line(0)[0].Content != "F" { + t.Errorf("expected oldest remaining line 'F', got %s", sb.Line(0)[0].Content) + } +} + +func TestScreen_ScrollUpWithScrollback(t *testing.T) { + term := newTestTerminal(t, 5, 3) + + // Fill screen with content + term.Write([]byte("Line1\r\n")) + term.Write([]byte("Line2\r\n")) + term.Write([]byte("Line3")) + + // Check initial scrollback is empty + if term.ScrollbackLen() != 0 { + t.Errorf("expected empty scrollback initially, got %d lines", term.ScrollbackLen()) + } + + // Write more lines to trigger scrolling + term.Write([]byte("\r\nLine4")) + + // One line should have scrolled into scrollback + if term.ScrollbackLen() != 1 { + t.Errorf("expected 1 line in scrollback, got %d", term.ScrollbackLen()) + } + + // Check the scrollback contains "Line1" + line := term.ScrollbackLine(0) + if line == nil { + t.Fatal("expected scrollback line, got nil") + } + + content := "" + for _, cell := range line { + if cell.Content != "" { + content += string(cell.Content) + } + } + if content != "Line1" { + t.Errorf("expected scrollback to contain 'Line1', got %q", content) + } +} + +func TestScreen_ClearScrollback(t *testing.T) { + term := newTestTerminal(t, 5, 2) + + // Fill and scroll + for i := 0; i < 5; i++ { + term.Write([]byte("Line\r\n")) + } + + if term.ScrollbackLen() == 0 { + t.Error("expected scrollback to have lines") + } + + // Clear scrollback + term.ClearScrollback() + + if term.ScrollbackLen() != 0 { + t.Errorf("expected empty scrollback after clear, got %d lines", term.ScrollbackLen()) + } +} + +func TestScreen_EraseDisplayWithScrollback(t *testing.T) { + term := newTestTerminal(t, 10, 3) + + // Fill and scroll + for i := 0; i < 10; i++ { + term.Write([]byte("TestLine\r\n")) + } + + if term.ScrollbackLen() == 0 { + t.Error("expected scrollback to have lines") + } + + // ED 3 should clear scrollback + term.Write([]byte("\x1b[3J")) + + if term.ScrollbackLen() != 0 { + t.Errorf("expected scrollback cleared after ED 3, got %d lines", term.ScrollbackLen()) + } +} + +func TestScreen_ScrollRegionNoScrollback(t *testing.T) { + term := newTestTerminal(t, 10, 5) + + // Set a scroll region that doesn't start at top + term.Write([]byte("\x1b[2;4r")) // Lines 2-4 + + // Move to scroll region and fill + term.Write([]byte("\x1b[2;1H")) + for i := 0; i < 5; i++ { + term.Write([]byte("Line\r\n")) + } + + // Scrolling within a limited region should NOT add to scrollback + if term.ScrollbackLen() != 0 { + t.Errorf("expected no scrollback for limited scroll region, got %d lines", term.ScrollbackLen()) + } +} + +func TestScrollback_EmptyLine(t *testing.T) { + sb := NewScrollback(100) + + // Empty lines should not be added + sb.PushLine([]uv.Cell{}) + + if sb.Len() != 0 { + t.Errorf("expected empty line not to be added, got %d lines", sb.Len()) + } +} + +func TestScrollback_OutOfBounds(t *testing.T) { + sb := NewScrollback(10) + sb.PushLine([]uv.Cell{{Content: "A", Width: 1}}) + + // Test negative index + if line := sb.Line(-1); line != nil { + t.Error("expected nil for negative index") + } + + // Test index beyond length + if line := sb.Line(10); line != nil { + t.Error("expected nil for out of bounds index") + } +} + +func TestScrollback_DefaultSize(t *testing.T) { + sb := NewScrollback(0) // Should use default + + if sb.MaxLines() != 10000 { + t.Errorf("expected default max lines 10000, got %d", sb.MaxLines()) + } + + sb2 := NewScrollback(-5) // Should also use default + if sb2.MaxLines() != 10000 { + t.Errorf("expected default max lines 10000 for negative input, got %d", sb2.MaxLines()) + } +} + +func TestScreen_AlternateScreenNoScrollback(t *testing.T) { + term := newTestTerminal(t, 10, 3) + + // Switch to alternate screen + term.Write([]byte("\x1b[?1049h")) + + // Fill alternate screen + for i := 0; i < 10; i++ { + term.Write([]byte("AltLine\r\n")) + } + + // Alternate screen scrolling should not affect main scrollback + // (alternate screen has its own scrollback, but we check main) + mainScrollback := term.scrs[0].ScrollbackLen() + if mainScrollback != 0 { + t.Errorf("expected no scrollback in main screen, got %d lines", mainScrollback) + } + + // Switch back + term.Write([]byte("\x1b[?1049l")) + + // Main scrollback should still be empty + if term.ScrollbackLen() != 0 { + t.Errorf("expected main scrollback to remain empty, got %d lines", term.ScrollbackLen()) + } +} + +func TestScrollback_LineCopy(t *testing.T) { + sb := NewScrollback(10) + + original := []uv.Cell{{Content: "A", Width: 1}} + sb.PushLine(original) + + // Modify original + original[0].Content = "B" + + // Retrieved line should still be 'A' (deep copy) + retrieved := sb.Line(0) + if retrieved[0].Content != "A" { + t.Errorf("expected line to be deep copied, got %s instead of 'A'", retrieved[0].Content) + } +} + +func TestExtractLine(t *testing.T) { + buf := uv.NewBuffer(5, 3) + + // Set some cells + buf.SetCell(0, 0, &uv.Cell{Content: "H", Width: 1}) + buf.SetCell(1, 0, &uv.Cell{Content: "i", Width: 1}) + + line := extractLine(buf, 0, 5) + + if len(line) != 5 { + t.Errorf("expected line length 5, got %d", len(line)) + } + + if line[0].Content != "H" || line[1].Content != "i" { + t.Errorf("extracted line content mismatch") + } + + // Remaining cells should be blank (width 1, but rune may be space) + if line[2].Width != 1 { + t.Errorf("expected blank cell width 1 at index 2, got %d", line[2].Width) + } +}