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
52 changes: 44 additions & 8 deletions vt/scrollback.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package vt

import (
"slices"
"sync"

uv "github.com/charmbracelet/ultraviolet"
)
Expand All @@ -13,6 +13,7 @@ const DefaultScrollbackSize = 10000
type Scrollback struct {
lines []uv.Line
maxLines int
linePool sync.Pool
}

// NewScrollback creates a new scrollback buffer with the given maximum number of lines.
Expand Down Expand Up @@ -44,14 +45,19 @@ func (s *Scrollback) Push(line uv.Line) {
}
}

// Clone the line content up to and including the last non-empty cell
cloned := slices.Clone(line[:lastNonEmpty+1])

trimmed := line[:lastNonEmpty+1]
var recycled uv.Line
if len(s.lines) >= s.maxLines {
// Remove oldest line and append new one
s.lines = slices.Delete(s.lines, 0, 1)
// Reuse the evicted line buffer when possible to avoid a fresh
// allocation for every push once scrollback is full.
recycled = s.lines[0]
copy(s.lines, s.lines[1:])
s.lines = s.lines[:len(s.lines)-1]
}
s.lines = append(s.lines, cloned)

dst := s.acquireLine(len(trimmed), recycled)
copy(dst, trimmed)
s.lines = append(s.lines, dst)
}

// PushN adds n lines from the buffer starting at line y to the scrollback.
Expand Down Expand Up @@ -92,7 +98,10 @@ func (s *Scrollback) SetMaxLines(maxLines int) {

s.maxLines = maxLines
if len(s.lines) > maxLines {
// Remove oldest lines
trimmed := s.lines[:len(s.lines)-maxLines]
for _, line := range trimmed {
s.releaseLine(line)
}
s.lines = s.lines[len(s.lines)-maxLines:]
}
}
Expand Down Expand Up @@ -121,9 +130,36 @@ func (s *Scrollback) Clear() {
if s == nil {
return
}
for _, line := range s.lines {
s.releaseLine(line)
}
s.lines = s.lines[:0]
}

func (s *Scrollback) acquireLine(size int, recycled uv.Line) uv.Line {
if cap(recycled) >= size {
return recycled[:size]
}
if recycled != nil {
s.releaseLine(recycled)
}
if v := s.linePool.Get(); v != nil {
pooled := v.(uv.Line)
if cap(pooled) >= size {
return pooled[:size]
}
s.releaseLine(pooled)
}
return make(uv.Line, size)
}

func (s *Scrollback) releaseLine(line uv.Line) {
if cap(line) == 0 {
return
}
s.linePool.Put(line[:0])
}

// CellAt returns the cell at the given position in the scrollback buffer.
// x is the column, y is the line index (0 = oldest).
// Returns nil if position is out of bounds.
Expand Down
37 changes: 37 additions & 0 deletions vt/scrollback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package vt

import (
"testing"

uv "github.com/charmbracelet/ultraviolet"
)

func TestScrollback(t *testing.T) {
Expand Down Expand Up @@ -87,6 +89,41 @@ func TestScrollback(t *testing.T) {
}
})

t.Run("push copies input line", func(t *testing.T) {
sb := NewScrollback(2)
src := uv.Line{{Content: "a", Width: 1}}
sb.Push(src)

src[0].Content = "mutated"
if got := sb.Line(0)[0].Content; got != "a" {
t.Fatalf("scrollback aliased source line, got %q", got)
}
})

t.Run("full scrollback reuses evicted buffer", func(t *testing.T) {
sb := NewScrollback(1)
sb.Push(uv.Line{{Content: "first", Width: 5}})

first := sb.Line(0)
if len(first) == 0 {
t.Fatal("expected stored line")
}
firstPtr := &first[0]

sb.Push(uv.Line{{Content: "second", Width: 6}})

second := sb.Line(0)
if len(second) == 0 {
t.Fatal("expected replacement line")
}
if got := second[0].Content; got != "second" {
t.Fatalf("replacement line content = %q, want %q", got, "second")
}
if firstPtr != &second[0] {
t.Fatal("expected scrollback to reuse the evicted line buffer")
}
})

t.Run("alt screen does not have scrollback", func(t *testing.T) {
e := NewEmulator(20, 5)

Expand Down