Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 27 additions & 0 deletions vt/emulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 3 additions & 4 deletions vt/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 75 additions & 2 deletions vt/screen.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package vt

import (
"sync"

uv "github.com/charmbracelet/ultraviolet"
)

Expand All @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current VT implementation doesn't use any mutexes and doesn't guarantee thread safety. Caller should handle that and I think it should stay that way for now

}

// 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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
105 changes: 105 additions & 0 deletions vt/scrollback.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lines [][]uv.Cell
lines []uv.Line

// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this into a const DefaultScrollbackSize = 10000

}
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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func (sb *Scrollback) Lines() [][]uv.Cell {
func (sb *Scrollback) Lines() []uv.Line {

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
maxLines = 10000 // Default scrollback size
maxLines = DefaultScrollbackSize

}
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
}
Loading