diff --git a/vt/scrollback.go b/vt/scrollback.go index 59d03a03..ba94fa44 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -1,7 +1,7 @@ package vt import ( - "slices" + "sync" uv "github.com/charmbracelet/ultraviolet" ) @@ -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. @@ -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. @@ -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:] } } @@ -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. diff --git a/vt/scrollback_test.go b/vt/scrollback_test.go index de7fc28b..fdc5fc29 100644 --- a/vt/scrollback_test.go +++ b/vt/scrollback_test.go @@ -2,6 +2,8 @@ package vt import ( "testing" + + uv "github.com/charmbracelet/ultraviolet" ) func TestScrollback(t *testing.T) { @@ -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)