From 998d0c0674970708a52d2212aeccc5150e2734c6 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 18:52:42 -0500 Subject: [PATCH 1/7] update to include reflow --- vt/csi.go | 2 +- vt/emulator.go | 32 +++---- vt/screen.go | 199 +++++++++++++++++++++++++++++++++++++++++- vt/scrollback.go | 111 ++++++++++++++++++++--- vt/scrollback_test.go | 135 +++++++++++++++++++++++++++- vt/utf8.go | 3 + 6 files changed, 454 insertions(+), 28 deletions(-) diff --git a/vt/csi.go b/vt/csi.go index 5bfa3103..e2c37192 100644 --- a/vt/csi.go +++ b/vt/csi.go @@ -36,7 +36,7 @@ func paramsString(cmd ansi.Cmd, params ansi.Params) string { s.WriteByte(mark) } params.ForEach(-1, func(i, p int, more bool) { - s.WriteString(fmt.Sprintf("%d", p)) + fmt.Fprintf(&s, "%d", p) if i < len(params)-1 { if more { s.WriteByte(':') diff --git a/vt/emulator.go b/vt/emulator.go index b67cce41..eba803fd 100644 --- a/vt/emulator.go +++ b/vt/emulator.go @@ -215,7 +215,9 @@ func (e *Emulator) CursorPosition() uv.Position { // Resize resizes the terminal. func (e *Emulator) Resize(width int, height int) { + oldWidth := e.scr.Width() x, y := e.scr.CursorPosition() + if e.atPhantom { if x < width-1 { e.atPhantom = false @@ -223,23 +225,23 @@ func (e *Emulator) Resize(width int, height int) { } } - if y < 0 { - y = 0 - } - if y >= height { - y = height - 1 - } - if x < 0 { - x = 0 - } - if x >= width { - x = width - 1 + // If width changed and autowrap is enabled, use reflow for primary screen + if oldWidth != width && e.isModeSet(ansi.ModeAutoWrap) { + // Reflow primary screen with cursor tracking + result := e.scrs[0].Reflow(width, height, x, y) + x, y = result.CursorX, result.CursorY + + // Alternate screen doesn't reflow (same as ghostty) + e.scrs[1].Resize(width, height) + } else { + // No reflow needed, just resize and clamp cursor + x = max(0, min(x, width-1)) + y = max(0, min(y, height-1)) + e.scrs[0].Resize(width, height) + e.scrs[1].Resize(width, height) } - e.scrs[0].Resize(width, height) - e.scrs[1].Resize(width, height) e.tabstops = uv.DefaultTabStops(width) - e.setCursor(x, y) if e.isModeSet(ansi.ModeInBandResize) { @@ -418,7 +420,7 @@ func (e *Emulator) IndexedColor(i int) color.Color { c := e.colors[i] if c == nil { // Return the default color. - return ansi.IndexedColor(i) //nolint:gosec + return ansi.IndexedColor(i) } return c diff --git a/vt/screen.go b/vt/screen.go index 04784f81..f29e0450 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -100,7 +100,8 @@ func (s *Screen) ClearWithScrollback() { for y := 0; y < s.buf.Height(); y++ { line := s.buf.Line(y) if line != nil && !s.isLineEmpty(line) { - s.scrollback.Push(line) + // Lines from clear screen are not wrapped (they're complete) + s.scrollback.Push(line, false) } } } @@ -391,6 +392,16 @@ func (s *Screen) touchArea(area uv.Rectangle) { } } +// setSoftWrapped sets the soft-wrapped state for the given line. +func (s *Screen) setSoftWrapped(y int, wrapped bool) { + s.buf.SetSoftWrapped(y, wrapped) +} + +// isSoftWrapped returns whether the given line is soft-wrapped. +func (s *Screen) isSoftWrapped(y int) bool { + return s.buf.IsSoftWrapped(y) +} + // Scrollback returns the screen's scrollback buffer. func (s *Screen) Scrollback() *Scrollback { return s.scrollback @@ -410,3 +421,189 @@ func (s *Screen) SetScrollbackSize(maxLines int) { s.scrollback.SetMaxLines(maxLines) } } + +// ReflowResult contains the results of a reflow operation. +type ReflowResult struct { + // CursorX is the new cursor X position after reflow. + CursorX int + // CursorY is the new cursor Y position after reflow. + CursorY int +} + +// Reflow reflows the screen content to a new width, properly handling +// soft-wrapped lines. This should be called during resize when the width +// changes and autowrap mode is enabled. The cursor position is tracked +// through the reflow and returned. +func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { + oldWidth := s.buf.Width() + oldHeight := s.buf.Height() + + // If width didn't change, just resize without reflow + if oldWidth == newWidth { + s.Resize(newWidth, newHeight) + // Clamp cursor to new bounds + if curY >= newHeight { + curY = newHeight - 1 + } + if curX >= newWidth { + curX = newWidth - 1 + } + return ReflowResult{CursorX: curX, CursorY: curY} + } + + // Collect all logical lines from screen (joining wrapped physical lines) + type logicalLine struct { + cells uv.Line + } + var logicalLines []logicalLine + + // Track cursor position as offset within logical lines + cursorLogicalLine := -1 + cursorLogicalOffset := 0 + + // Process screen lines + var current uv.Line + var currentStartY int + for y := 0; y < oldHeight; y++ { + line := s.buf.Line(y) + if line == nil { + line = uv.NewLine(oldWidth) + } + + // Track cursor position + if y == curY { + cursorLogicalLine = len(logicalLines) + cursorLogicalOffset = len(current) + curX + } + + // Trim trailing empty cells from non-wrapped lines + trimmed := line + if !s.isSoftWrapped(y) { + trimmed = trimTrailingEmpty(line) + } + current = append(current, trimmed...) + + if !s.isSoftWrapped(y) { + // End of logical line + logicalLines = append(logicalLines, logicalLine{cells: current}) + current = nil + currentStartY = y + 1 + } + } + // Handle trailing wrapped line + if len(current) > 0 { + logicalLines = append(logicalLines, logicalLine{cells: current}) + } + _ = currentStartY // silence unused warning + + // Create new buffer with new dimensions + newBuf := uv.NewRenderBuffer(newWidth, newHeight) + + // Find the last logical line that has content or contains the cursor + lastUsedLine := -1 + for i, logical := range logicalLines { + if len(logical.cells) > 0 || i == cursorLogicalLine { + lastUsedLine = i + } + } + + // Re-wrap logical lines to new width and write to new buffer + newCursorX, newCursorY := curX, curY + writeY := 0 + for logIdx := 0; logIdx <= lastUsedLine && writeY < newHeight; logIdx++ { + logical := logicalLines[logIdx] + + // Handle cursor on empty line + if logIdx == cursorLogicalLine && len(logical.cells) == 0 { + newCursorX = 0 + newCursorY = writeY + cursorLogicalLine = -1 // Mark as found + writeY++ + continue + } + + if len(logical.cells) == 0 { + // Empty logical line (but not cursor line) + writeY++ + continue + } + + cells := logical.cells + for len(cells) > 0 && writeY < newHeight { + // Take up to newWidth cells + chunk := cells + if len(chunk) > newWidth { + chunk = cells[:newWidth] + cells = cells[newWidth:] + // Mark this line as wrapped + newBuf.SetSoftWrapped(writeY, true) + } else { + cells = nil + } + + // Write chunk to new buffer + for x, cell := range chunk { + newBuf.SetCell(x, writeY, &cell) + } + + // Track cursor position + if logIdx == cursorLogicalLine { + // Cursor is in this logical line + // Use <= to handle cursor at end of chunk (e.g., after last char) + if cursorLogicalOffset <= len(chunk) { + newCursorX = cursorLogicalOffset + newCursorY = writeY + cursorLogicalLine = -1 // Mark as found + } else { + cursorLogicalOffset -= len(chunk) + } + } + + writeY++ + } + } + + // If cursor wasn't found (was past end of content), put at end + if cursorLogicalLine >= 0 { + newCursorY = max(0, writeY-1) + newCursorX = min(cursorLogicalOffset, newWidth-1) + } + + // Clamp cursor to valid bounds + if newCursorY >= newHeight { + newCursorY = newHeight - 1 + } + if newCursorX >= newWidth { + newCursorX = newWidth - 1 + } + if newCursorX < 0 { + newCursorX = 0 + } + if newCursorY < 0 { + newCursorY = 0 + } + + // Replace buffer + s.buf = newBuf + s.scroll = s.buf.Bounds() + + // Reflow scrollback if present + if s.scrollback != nil { + s.scrollback.Reflow(newWidth) + } + + return ReflowResult{CursorX: newCursorX, CursorY: newCursorY} +} + +// trimTrailingEmpty removes trailing empty cells from a line. +func trimTrailingEmpty(line uv.Line) uv.Line { + lastNonEmpty := -1 + for i := len(line) - 1; i >= 0; i-- { + c := &line[i] + if !c.IsZero() && !c.Equal(&uv.EmptyCell) { + lastNonEmpty = i + break + } + } + return line[:lastNonEmpty+1] +} diff --git a/vt/scrollback.go b/vt/scrollback.go index 59d03a03..775f8a88 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -9,9 +9,19 @@ import ( // DefaultScrollbackSize is the default maximum number of lines in the scrollback buffer. const DefaultScrollbackSize = 10000 +// ScrollbackLine represents a line in the scrollback buffer with metadata. +type ScrollbackLine struct { + // Cells contains the cell data for this line. + Cells uv.Line + // SoftWrapped indicates this line was soft-wrapped (continued on next line + // due to terminal width, not an explicit newline). This enables proper + // reflow on terminal resize. + SoftWrapped bool +} + // Scrollback represents a scrollback buffer that stores lines scrolled off the screen. type Scrollback struct { - lines []uv.Line + lines []ScrollbackLine maxLines int } @@ -21,14 +31,15 @@ func NewScrollback(maxLines int) *Scrollback { maxLines = DefaultScrollbackSize } return &Scrollback{ - lines: make([]uv.Line, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity + lines: make([]ScrollbackLine, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity maxLines: maxLines, } } // Push adds a line to the scrollback buffer. // If the buffer is full, the oldest line is removed. -func (s *Scrollback) Push(line uv.Line) { +// The wrapped parameter indicates if this line was soft-wrapped (no explicit newline). +func (s *Scrollback) Push(line uv.Line, wrapped bool) { if s == nil || s.maxLines <= 0 { return } @@ -47,23 +58,34 @@ 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]) + entry := ScrollbackLine{ + Cells: cloned, + SoftWrapped: wrapped, + } + if len(s.lines) >= s.maxLines { // Remove oldest line and append new one s.lines = slices.Delete(s.lines, 0, 1) } - s.lines = append(s.lines, cloned) + s.lines = append(s.lines, entry) } // PushN adds n lines from the buffer starting at line y to the scrollback. +// Lines are marked as wrapped based on the buffer's wrap state for each line. func (s *Scrollback) PushN(buf *uv.RenderBuffer, y, n int) { if s == nil || buf == nil || n <= 0 { return } for i := range min(n, buf.Height()-y) { - if line := buf.Line(y + i); line != nil { - s.Push(line) + line := buf.Line(y + i) + if line == nil { + continue } + + // Use the buffer's tracked wrap state for this line + wrapped := buf.IsSoftWrapped(y + i) + s.Push(line, wrapped) } } @@ -97,23 +119,37 @@ func (s *Scrollback) SetMaxLines(maxLines int) { } } -// Line returns the line at the given index. +// Line returns the line cells at the given index. // Index 0 is the oldest line, Len()-1 is the most recent. // Returns nil if index is out of bounds. func (s *Scrollback) Line(index int) uv.Line { if s == nil || index < 0 || index >= len(s.lines) { return nil } - return s.lines[index] + return s.lines[index].Cells +} + +// LineEntry returns the full ScrollbackLine at the given index. +// Index 0 is the oldest line, Len()-1 is the most recent. +// Returns nil if index is out of bounds. +func (s *Scrollback) LineEntry(index int) *ScrollbackLine { + if s == nil || index < 0 || index >= len(s.lines) { + return nil + } + return &s.lines[index] } -// Lines returns all lines in the scrollback buffer. +// Lines returns all line cells in the scrollback buffer. // Index 0 is the oldest line. func (s *Scrollback) Lines() []uv.Line { if s == nil { return nil } - return s.lines + result := make([]uv.Line, len(s.lines)) + for i, entry := range s.lines { + result[i] = entry.Cells + } + return result } // Clear removes all lines from the scrollback buffer. @@ -134,3 +170,58 @@ func (s *Scrollback) CellAt(x, y int) *uv.Cell { } return &line[x] } + +// Reflow reflows the scrollback buffer for a new terminal width. +// Lines that were soft-wrapped are joined and re-wrapped to the new width. +// This should be called when the terminal is resized. +func (s *Scrollback) Reflow(newWidth int) { + if s == nil || len(s.lines) == 0 || newWidth <= 0 { + return + } + + // Collect all logical lines (joining wrapped physical lines) + var logicalLines []uv.Line + var current uv.Line + + for _, entry := range s.lines { + current = append(current, entry.Cells...) + if !entry.SoftWrapped { + // End of logical line + logicalLines = append(logicalLines, current) + current = nil + } + } + // Handle trailing wrapped line + if len(current) > 0 { + logicalLines = append(logicalLines, current) + } + + // Re-wrap logical lines to new width + s.lines = s.lines[:0] + for _, logical := range logicalLines { + if len(logical) == 0 { + s.lines = append(s.lines, ScrollbackLine{Cells: nil, SoftWrapped: false}) + continue + } + + // Split into chunks of newWidth + for len(logical) > newWidth { + chunk := slices.Clone(logical[:newWidth]) + s.lines = append(s.lines, ScrollbackLine{Cells: chunk, SoftWrapped: true}) + logical = logical[newWidth:] + + // Enforce max lines + if len(s.lines) >= s.maxLines { + s.lines = slices.Delete(s.lines, 0, 1) + } + } + + // Final chunk (not wrapped) + if len(logical) > 0 { + s.lines = append(s.lines, ScrollbackLine{Cells: slices.Clone(logical), SoftWrapped: false}) + if len(s.lines) >= s.maxLines { + s.lines = slices.Delete(s.lines, 0, 1) + } + } + } +} diff --git a/vt/scrollback_test.go b/vt/scrollback_test.go index de7fc28b..01a2aa02 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) { @@ -58,7 +60,7 @@ func TestScrollback(t *testing.T) { // Push more lines than max for i := 0; i < 10; i++ { - sb.Push(nil) + sb.Push(nil, false) } if sb.Len() != 5 { @@ -164,3 +166,134 @@ func TestScrollback(t *testing.T) { } }) } + +func TestReflow(t *testing.T) { + t.Run("basic reflow shrink", func(t *testing.T) { + // Create terminal with 20 cols + e := NewEmulator(20, 5) + + // Write a line that will wrap when shrunk to 10 cols + e.WriteString("12345678901234567890") + + // Resize to 10 cols - should reflow + e.Resize(10, 5) + + // First row should have "1234567890" + var line0 string + for x := 0; x < 10; x++ { + cell := e.CellAt(x, 0) + if cell != nil && cell.Content != "" && cell.Content != " " { + line0 += cell.Content + } + } + + // Second row should have "1234567890" + var line1 string + for x := 0; x < 10; x++ { + cell := e.CellAt(x, 1) + if cell != nil && cell.Content != "" && cell.Content != " " { + line1 += cell.Content + } + } + + if line0 != "1234567890" { + t.Errorf("line 0: expected '1234567890', got %q", line0) + } + if line1 != "1234567890" { + t.Errorf("line 1: expected '1234567890', got %q", line1) + } + }) + + t.Run("reflow grow unwraps", func(t *testing.T) { + // Create terminal with 10 cols + e := NewEmulator(10, 5) + + // Write text that wraps + e.WriteString("1234567890ABCDEFGHIJ") + + // Resize to 20 cols - should unwrap + e.Resize(20, 5) + + // First row should now have all 20 chars + var line0 string + for x := 0; x < 20; x++ { + cell := e.CellAt(x, 0) + if cell != nil && cell.Content != "" && cell.Content != " " { + line0 += cell.Content + } + } + + if line0 != "1234567890ABCDEFGHIJ" { + t.Errorf("expected '1234567890ABCDEFGHIJ', got %q", line0) + } + }) + + t.Run("reflow cursor tracking", func(t *testing.T) { + // Create terminal with 20 cols + e := NewEmulator(20, 5) + + // Write some text and move cursor + e.WriteString("Hello, World!") + // Cursor should be at position 13 (after the !) + + pos := e.CursorPosition() + if pos.X != 13 || pos.Y != 0 { + t.Errorf("initial cursor: expected (13,0), got (%d,%d)", pos.X, pos.Y) + } + + // Resize - cursor should stay at same logical position + e.Resize(10, 5) + + pos = e.CursorPosition() + // "Hello, Wor" on line 0 (10 chars) + // "ld!" on line 1 (3 chars) + // Cursor was at offset 13, which is now (3, 1) + if pos.X != 3 || pos.Y != 1 { + t.Errorf("after resize: expected (3,1), got (%d,%d)", pos.X, pos.Y) + } + }) + + t.Run("scrollback reflow", func(t *testing.T) { + sb := NewScrollback(100) + + // Push lines that represent soft-wrapped content + line1 := make([]byte, 10) + for i := range line1 { + line1[i] = byte('A' + i) + } + // Convert to uv.Line manually + uvLine1 := make([]uv.Cell, 10) + for i, b := range line1 { + uvLine1[i] = uv.Cell{Content: string(b), Width: 1} + } + sb.Push(uvLine1, true) // soft-wrapped + + line2 := make([]byte, 5) + for i := range line2 { + line2[i] = byte('K' + i) + } + uvLine2 := make([]uv.Cell, 5) + for i, b := range line2 { + uvLine2[i] = uv.Cell{Content: string(b), Width: 1} + } + sb.Push(uvLine2, false) // not soft-wrapped + + // Should have 2 lines + if sb.Len() != 2 { + t.Errorf("expected 2 lines, got %d", sb.Len()) + } + + // Reflow to wider width (20) - should unwrap into 1 line + sb.Reflow(20) + + if sb.Len() != 1 { + t.Errorf("after reflow to 20: expected 1 line, got %d", sb.Len()) + } + + // Check content + resultLine := sb.Line(0) + if len(resultLine) != 15 { + t.Errorf("expected 15 cells, got %d", len(resultLine)) + } + }) +} diff --git a/vt/utf8.go b/vt/utf8.go index d04c2a70..441c008f 100644 --- a/vt/utf8.go +++ b/vt/utf8.go @@ -52,6 +52,9 @@ func (e *Emulator) handleGrapheme(content string, width int) { x, y := e.scr.CursorPosition() if e.atPhantom && awm { + // Mark current line as soft-wrapped before moving to next line. + // This enables proper reflow on resize. + e.scr.setSoftWrapped(y, true) // moves cursor down similar to [Terminal.linefeed] except it doesn't // respects [ansi.LNM] mode. // This will reset the phantom state i.e. pending wrap state. From d689d206b316c6fc1058872214a19c2a7160b224 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 19:11:09 -0500 Subject: [PATCH 2/7] remove uv changes dep --- vt/screen.go | 55 ++++++++++++++++++++++++++++++++++++++++++------ vt/scrollback.go | 11 ++++++---- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/vt/screen.go b/vt/screen.go index f29e0450..b3c1aaef 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -17,13 +17,17 @@ type Screen struct { scroll uv.Rectangle // scrollback is the scrollback buffer for lines scrolled off the top. scrollback *Scrollback + // softWrapped tracks which lines are soft-wrapped (continued on next line + // due to terminal width). This enables proper reflow on resize. + softWrapped []bool } // NewScreen creates a new screen. func NewScreen(w, h int) *Screen { s := Screen{ - buf: uv.NewRenderBuffer(w, h), - scrollback: NewScrollback(DefaultScrollbackSize), + buf: uv.NewRenderBuffer(w, h), + scrollback: NewScrollback(DefaultScrollbackSize), + softWrapped: make([]bool, h), } s.scroll = s.buf.Bounds() return &s @@ -38,6 +42,7 @@ func (s *Screen) Reset() { s.saved = Cursor{} s.scroll = s.buf.Bounds() s.buf.Touched = nil + clear(s.softWrapped) } // Bounds returns the bounds of the screen. @@ -74,9 +79,16 @@ func (s *Screen) Height() int { func (s *Screen) Resize(width int, height int) { if s.buf == nil { s.buf = uv.NewRenderBuffer(width, height) + s.softWrapped = make([]bool, height) } else { s.buf.Resize(width, height) s.buf.Touched = nil + // Resize softWrapped slice + if height > len(s.softWrapped) { + s.softWrapped = append(s.softWrapped, make([]bool, height-len(s.softWrapped))...) + } else if height < len(s.softWrapped) { + s.softWrapped = s.softWrapped[:height] + } } s.scroll = s.buf.Bounds() } @@ -334,6 +346,17 @@ func (s *Screen) InsertLine(n int) bool { s.buf.InsertLineArea(y, n, s.blankCell(), s.scroll) + // Shift softWrapped state down within the scroll region + if y >= 0 && y < len(s.softWrapped) { + for i := min(s.scroll.Max.Y-1, len(s.softWrapped)-1); i >= y+n; i-- { + s.softWrapped[i] = s.softWrapped[i-n] + } + // Clear the newly inserted lines' wrap state + for i := y; i < y+n && i < len(s.softWrapped); i++ { + s.softWrapped[i] = false + } + } + return true } @@ -364,11 +387,22 @@ func (s *Screen) DeleteLine(n int) bool { scroll.Min.X == 0 && scroll.Max.X == s.buf.Width() { // Save lines that will be deleted linesToSave := min(n, scroll.Max.Y-y) - s.scrollback.PushN(s.buf, y, linesToSave) + s.scrollback.PushN(s.buf, y, linesToSave, s.softWrapped) } s.buf.DeleteLineArea(y, n, s.blankCell(), scroll) + // Shift softWrapped state up within the scroll region + if y >= 0 && y < len(s.softWrapped) { + for i := y; i < scroll.Max.Y-n && i+n < len(s.softWrapped); i++ { + s.softWrapped[i] = s.softWrapped[i+n] + } + // Clear the newly created lines at the bottom + for i := max(y, scroll.Max.Y-n); i < scroll.Max.Y && i < len(s.softWrapped); i++ { + s.softWrapped[i] = false + } + } + return true } @@ -394,12 +428,17 @@ func (s *Screen) touchArea(area uv.Rectangle) { // setSoftWrapped sets the soft-wrapped state for the given line. func (s *Screen) setSoftWrapped(y int, wrapped bool) { - s.buf.SetSoftWrapped(y, wrapped) + if y >= 0 && y < len(s.softWrapped) { + s.softWrapped[y] = wrapped + } } // isSoftWrapped returns whether the given line is soft-wrapped. func (s *Screen) isSoftWrapped(y int) bool { - return s.buf.IsSoftWrapped(y) + if y >= 0 && y < len(s.softWrapped) { + return s.softWrapped[y] + } + return false } // Scrollback returns the screen's scrollback buffer. @@ -498,6 +537,7 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { // Create new buffer with new dimensions newBuf := uv.NewRenderBuffer(newWidth, newHeight) + newSoftWrapped := make([]bool, newHeight) // Find the last logical line that has content or contains the cursor lastUsedLine := -1 @@ -536,7 +576,7 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { chunk = cells[:newWidth] cells = cells[newWidth:] // Mark this line as wrapped - newBuf.SetSoftWrapped(writeY, true) + newSoftWrapped[writeY] = true } else { cells = nil } @@ -583,8 +623,9 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { newCursorY = 0 } - // Replace buffer + // Replace buffer and softWrapped s.buf = newBuf + s.softWrapped = newSoftWrapped s.scroll = s.buf.Bounds() // Reflow scrollback if present diff --git a/vt/scrollback.go b/vt/scrollback.go index 775f8a88..710dfaf2 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -71,8 +71,8 @@ func (s *Scrollback) Push(line uv.Line, wrapped bool) { } // PushN adds n lines from the buffer starting at line y to the scrollback. -// Lines are marked as wrapped based on the buffer's wrap state for each line. -func (s *Scrollback) PushN(buf *uv.RenderBuffer, y, n int) { +// The softWrapped slice indicates which lines are soft-wrapped. +func (s *Scrollback) PushN(buf *uv.RenderBuffer, y, n int, softWrapped []bool) { if s == nil || buf == nil || n <= 0 { return } @@ -83,8 +83,11 @@ func (s *Scrollback) PushN(buf *uv.RenderBuffer, y, n int) { continue } - // Use the buffer's tracked wrap state for this line - wrapped := buf.IsSoftWrapped(y + i) + // Check if this line is soft-wrapped + wrapped := false + if lineIdx := y + i; lineIdx >= 0 && lineIdx < len(softWrapped) { + wrapped = softWrapped[lineIdx] + } s.Push(line, wrapped) } } From a3d12dba285b1fc0e79a4ced25568607e5f73f37 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 19:13:18 -0500 Subject: [PATCH 3/7] fixup gosec --- vt/emulator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vt/emulator.go b/vt/emulator.go index eba803fd..58e3765b 100644 --- a/vt/emulator.go +++ b/vt/emulator.go @@ -420,7 +420,7 @@ func (e *Emulator) IndexedColor(i int) color.Color { c := e.colors[i] if c == nil { // Return the default color. - return ansi.IndexedColor(i) + return ansi.IndexedColor(uint8(i)) // #nosec G115 -- i is bounds-checked above (0-255) } return c From 18ec502d44398fec3ff4d00c6e9df906a027fdd5 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 19:51:29 -0500 Subject: [PATCH 4/7] address feedback --- vt/screen.go | 49 +++++++++++++++++++++++++++++++----------------- vt/scrollback.go | 22 ---------------------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/vt/screen.go b/vt/screen.go index b3c1aaef..838c9d18 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -387,7 +387,7 @@ func (s *Screen) DeleteLine(n int) bool { scroll.Min.X == 0 && scroll.Max.X == s.buf.Width() { // Save lines that will be deleted linesToSave := min(n, scroll.Max.Y-y) - s.scrollback.PushN(s.buf, y, linesToSave, s.softWrapped) + s.pushLinesToScrollback(y, linesToSave) } s.buf.DeleteLineArea(y, n, s.blankCell(), scroll) @@ -441,6 +441,29 @@ func (s *Screen) isSoftWrapped(y int) bool { return false } +// pushLinesToScrollback pushes n lines starting at y to the scrollback buffer. +func (s *Screen) pushLinesToScrollback(y, n int) { + if s.scrollback == nil || n <= 0 { + return + } + for i := range min(n, s.buf.Height()-y) { + line := s.buf.Line(y + i) + if line == nil { + continue + } + wrapped := s.isSoftWrapped(y + i) + s.scrollback.Push(line, wrapped) + } +} + +// reflowScrollback reflows the scrollback buffer for a new terminal width. +func (s *Screen) reflowScrollback(newWidth int) { + if s.scrollback == nil { + return + } + s.scrollback.Reflow(newWidth) +} + // Scrollback returns the screen's scrollback buffer. func (s *Screen) Scrollback() *Scrollback { return s.scrollback @@ -491,10 +514,7 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { } // Collect all logical lines from screen (joining wrapped physical lines) - type logicalLine struct { - cells uv.Line - } - var logicalLines []logicalLine + var logicalLines []uv.Line // Track cursor position as offset within logical lines cursorLogicalLine := -1 @@ -502,7 +522,6 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { // Process screen lines var current uv.Line - var currentStartY int for y := 0; y < oldHeight; y++ { line := s.buf.Line(y) if line == nil { @@ -524,16 +543,14 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { if !s.isSoftWrapped(y) { // End of logical line - logicalLines = append(logicalLines, logicalLine{cells: current}) + logicalLines = append(logicalLines, current) current = nil - currentStartY = y + 1 } } // Handle trailing wrapped line if len(current) > 0 { - logicalLines = append(logicalLines, logicalLine{cells: current}) + logicalLines = append(logicalLines, current) } - _ = currentStartY // silence unused warning // Create new buffer with new dimensions newBuf := uv.NewRenderBuffer(newWidth, newHeight) @@ -542,7 +559,7 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { // Find the last logical line that has content or contains the cursor lastUsedLine := -1 for i, logical := range logicalLines { - if len(logical.cells) > 0 || i == cursorLogicalLine { + if len(logical) > 0 || i == cursorLogicalLine { lastUsedLine = i } } @@ -554,7 +571,7 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { logical := logicalLines[logIdx] // Handle cursor on empty line - if logIdx == cursorLogicalLine && len(logical.cells) == 0 { + if logIdx == cursorLogicalLine && len(logical) == 0 { newCursorX = 0 newCursorY = writeY cursorLogicalLine = -1 // Mark as found @@ -562,13 +579,13 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { continue } - if len(logical.cells) == 0 { + if len(logical) == 0 { // Empty logical line (but not cursor line) writeY++ continue } - cells := logical.cells + cells := logical for len(cells) > 0 && writeY < newHeight { // Take up to newWidth cells chunk := cells @@ -629,9 +646,7 @@ func (s *Screen) Reflow(newWidth, newHeight int, curX, curY int) ReflowResult { s.scroll = s.buf.Bounds() // Reflow scrollback if present - if s.scrollback != nil { - s.scrollback.Reflow(newWidth) - } + s.reflowScrollback(newWidth) return ReflowResult{CursorX: newCursorX, CursorY: newCursorY} } diff --git a/vt/scrollback.go b/vt/scrollback.go index 710dfaf2..fc6c0314 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -70,28 +70,6 @@ func (s *Scrollback) Push(line uv.Line, wrapped bool) { s.lines = append(s.lines, entry) } -// PushN adds n lines from the buffer starting at line y to the scrollback. -// The softWrapped slice indicates which lines are soft-wrapped. -func (s *Scrollback) PushN(buf *uv.RenderBuffer, y, n int, softWrapped []bool) { - if s == nil || buf == nil || n <= 0 { - return - } - - for i := range min(n, buf.Height()-y) { - line := buf.Line(y + i) - if line == nil { - continue - } - - // Check if this line is soft-wrapped - wrapped := false - if lineIdx := y + i; lineIdx >= 0 && lineIdx < len(softWrapped) { - wrapped = softWrapped[lineIdx] - } - s.Push(line, wrapped) - } -} - // Len returns the number of lines in the scrollback buffer. func (s *Scrollback) Len() int { if s == nil { From 346623b6a1c08b7e9a51961255803dfbb6e20767 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Thu, 5 Mar 2026 19:57:03 -0500 Subject: [PATCH 5/7] fmt, address feedback --- vt/screen.go | 58 +++++++++++++++++++++++++++++++++++++++- vt/scrollback.go | 61 ++++++++----------------------------------- vt/scrollback_test.go | 8 ++++-- 3 files changed, 74 insertions(+), 53 deletions(-) diff --git a/vt/screen.go b/vt/screen.go index 838c9d18..eab19e08 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -1,6 +1,8 @@ package vt import ( + "slices" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/exp/ordered" ) @@ -457,11 +459,65 @@ func (s *Screen) pushLinesToScrollback(y, n int) { } // reflowScrollback reflows the scrollback buffer for a new terminal width. +// Lines that were soft-wrapped are joined and re-wrapped to the new width. func (s *Screen) reflowScrollback(newWidth int) { if s.scrollback == nil { return } - s.scrollback.Reflow(newWidth) + + entries := s.scrollback.entries() + if len(entries) == 0 || newWidth <= 0 { + return + } + + // Collect all logical lines (joining wrapped physical lines) + var logicalLines []uv.Line + var current uv.Line + + for _, entry := range entries { + current = append(current, entry.Cells...) + if !entry.SoftWrapped { + // End of logical line + logicalLines = append(logicalLines, current) + current = nil + } + } + // Handle trailing wrapped line + if len(current) > 0 { + logicalLines = append(logicalLines, current) + } + + // Re-wrap logical lines to new width + maxLines := s.scrollback.MaxLines() + var newLines []ScrollbackLine + for _, logical := range logicalLines { + if len(logical) == 0 { + newLines = append(newLines, ScrollbackLine{Cells: nil, SoftWrapped: false}) + continue + } + + // Split into chunks of newWidth + for len(logical) > newWidth { + chunk := slices.Clone(logical[:newWidth]) + newLines = append(newLines, ScrollbackLine{Cells: chunk, SoftWrapped: true}) + logical = logical[newWidth:] + + // Enforce max lines + if len(newLines) >= maxLines { + newLines = slices.Delete(newLines, 0, 1) + } + } + + // Final chunk (not wrapped) + if len(logical) > 0 { + newLines = append(newLines, ScrollbackLine{Cells: slices.Clone(logical), SoftWrapped: false}) + if len(newLines) >= maxLines { + newLines = slices.Delete(newLines, 0, 1) + } + } + } + + s.scrollback.replaceLines(newLines) } // Scrollback returns the screen's scrollback buffer. diff --git a/vt/scrollback.go b/vt/scrollback.go index fc6c0314..fd45738d 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -152,57 +152,18 @@ func (s *Scrollback) CellAt(x, y int) *uv.Cell { return &line[x] } -// Reflow reflows the scrollback buffer for a new terminal width. -// Lines that were soft-wrapped are joined and re-wrapped to the new width. -// This should be called when the terminal is resized. -func (s *Scrollback) Reflow(newWidth int) { - if s == nil || len(s.lines) == 0 || newWidth <= 0 { - return - } - - // Collect all logical lines (joining wrapped physical lines) - var logicalLines []uv.Line - var current uv.Line - - for _, entry := range s.lines { - current = append(current, entry.Cells...) - if !entry.SoftWrapped { - // End of logical line - logicalLines = append(logicalLines, current) - current = nil - } - } - // Handle trailing wrapped line - if len(current) > 0 { - logicalLines = append(logicalLines, current) +// entries returns the raw slice of scrollback entries for iteration. +func (s *Scrollback) entries() []ScrollbackLine { + if s == nil { + return nil } + return s.lines +} - // Re-wrap logical lines to new width - s.lines = s.lines[:0] - for _, logical := range logicalLines { - if len(logical) == 0 { - s.lines = append(s.lines, ScrollbackLine{Cells: nil, SoftWrapped: false}) - continue - } - - // Split into chunks of newWidth - for len(logical) > newWidth { - chunk := slices.Clone(logical[:newWidth]) - s.lines = append(s.lines, ScrollbackLine{Cells: chunk, SoftWrapped: true}) - logical = logical[newWidth:] - - // Enforce max lines - if len(s.lines) >= s.maxLines { - s.lines = slices.Delete(s.lines, 0, 1) - } - } - - // Final chunk (not wrapped) - if len(logical) > 0 { - s.lines = append(s.lines, ScrollbackLine{Cells: slices.Clone(logical), SoftWrapped: false}) - if len(s.lines) >= s.maxLines { - s.lines = slices.Delete(s.lines, 0, 1) - } - } +// replaceLines replaces all lines in the scrollback buffer. +func (s *Scrollback) replaceLines(lines []ScrollbackLine) { + if s == nil { + return } + s.lines = lines } diff --git a/vt/scrollback_test.go b/vt/scrollback_test.go index 01a2aa02..ced8a072 100644 --- a/vt/scrollback_test.go +++ b/vt/scrollback_test.go @@ -254,7 +254,11 @@ func TestReflow(t *testing.T) { }) t.Run("scrollback reflow", func(t *testing.T) { + // Create a screen with scrollback to test reflow + // Start with width 10 (same as line1 length) so we can reflow to 20 + scr := NewScreen(10, 10) sb := NewScrollback(100) + scr.SetScrollback(sb) // Push lines that represent soft-wrapped content line1 := make([]byte, 10) @@ -283,8 +287,8 @@ func TestReflow(t *testing.T) { t.Errorf("expected 2 lines, got %d", sb.Len()) } - // Reflow to wider width (20) - should unwrap into 1 line - sb.Reflow(20) + // Reflow via screen resize (width change triggers reflow) + scr.Reflow(20, 10, 0, 0) if sb.Len() != 1 { t.Errorf("after reflow to 20: expected 1 line, got %d", sb.Len()) From 20a2baa7d25f966691d3a7206ce8a68a4051d3ff Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Fri, 6 Mar 2026 16:43:32 +0000 Subject: [PATCH 6/7] refactor: simplify Scrollback to store plain lines Remove ScrollbackLine struct - Scrollback now stores []uv.Line with a parallel []bool for soft-wrap state. Remove entries() and replaceLines() methods; Screen.reflowScrollback() accesses the fields directly since it owns the reflow logic. Addresses review feedback from @aymanbagabas. --- vt/screen.go | 31 +++++++++++++++-------- vt/scrollback.go | 66 ++++++++++-------------------------------------- 2 files changed, 34 insertions(+), 63 deletions(-) diff --git a/vt/screen.go b/vt/screen.go index eab19e08..6151e62e 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -465,8 +465,9 @@ func (s *Screen) reflowScrollback(newWidth int) { return } - entries := s.scrollback.entries() - if len(entries) == 0 || newWidth <= 0 { + sb := s.scrollback + n := sb.Len() + if n == 0 || newWidth <= 0 { return } @@ -474,9 +475,9 @@ func (s *Screen) reflowScrollback(newWidth int) { var logicalLines []uv.Line var current uv.Line - for _, entry := range entries { - current = append(current, entry.Cells...) - if !entry.SoftWrapped { + for i := range n { + current = append(current, sb.lines[i]...) + if !sb.wrapped[i] { // End of logical line logicalLines = append(logicalLines, current) current = nil @@ -488,36 +489,44 @@ func (s *Screen) reflowScrollback(newWidth int) { } // Re-wrap logical lines to new width - maxLines := s.scrollback.MaxLines() - var newLines []ScrollbackLine + maxLines := sb.MaxLines() + newLines := make([]uv.Line, 0, n) + newWrapped := make([]bool, 0, n) + for _, logical := range logicalLines { if len(logical) == 0 { - newLines = append(newLines, ScrollbackLine{Cells: nil, SoftWrapped: false}) + newLines = append(newLines, nil) + newWrapped = append(newWrapped, false) continue } // Split into chunks of newWidth for len(logical) > newWidth { chunk := slices.Clone(logical[:newWidth]) - newLines = append(newLines, ScrollbackLine{Cells: chunk, SoftWrapped: true}) + newLines = append(newLines, chunk) + newWrapped = append(newWrapped, true) logical = logical[newWidth:] // Enforce max lines if len(newLines) >= maxLines { newLines = slices.Delete(newLines, 0, 1) + newWrapped = slices.Delete(newWrapped, 0, 1) } } // Final chunk (not wrapped) if len(logical) > 0 { - newLines = append(newLines, ScrollbackLine{Cells: slices.Clone(logical), SoftWrapped: false}) + newLines = append(newLines, slices.Clone(logical)) + newWrapped = append(newWrapped, false) if len(newLines) >= maxLines { newLines = slices.Delete(newLines, 0, 1) + newWrapped = slices.Delete(newWrapped, 0, 1) } } } - s.scrollback.replaceLines(newLines) + sb.lines = newLines + sb.wrapped = newWrapped } // Scrollback returns the screen's scrollback buffer. diff --git a/vt/scrollback.go b/vt/scrollback.go index fd45738d..9a7f7b71 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -9,19 +9,10 @@ import ( // DefaultScrollbackSize is the default maximum number of lines in the scrollback buffer. const DefaultScrollbackSize = 10000 -// ScrollbackLine represents a line in the scrollback buffer with metadata. -type ScrollbackLine struct { - // Cells contains the cell data for this line. - Cells uv.Line - // SoftWrapped indicates this line was soft-wrapped (continued on next line - // due to terminal width, not an explicit newline). This enables proper - // reflow on terminal resize. - SoftWrapped bool -} - // Scrollback represents a scrollback buffer that stores lines scrolled off the screen. type Scrollback struct { - lines []ScrollbackLine + lines []uv.Line + wrapped []bool maxLines int } @@ -30,16 +21,18 @@ func NewScrollback(maxLines int) *Scrollback { if maxLines <= 0 { maxLines = DefaultScrollbackSize } + cap := min(maxLines, 1000) // Pre-allocate reasonable capacity return &Scrollback{ - lines: make([]ScrollbackLine, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity + lines: make([]uv.Line, 0, cap), + wrapped: make([]bool, 0, cap), maxLines: maxLines, } } // Push adds a line to the scrollback buffer. // If the buffer is full, the oldest line is removed. -// The wrapped parameter indicates if this line was soft-wrapped (no explicit newline). -func (s *Scrollback) Push(line uv.Line, wrapped bool) { +// The softWrapped parameter indicates if this line was soft-wrapped (no explicit newline). +func (s *Scrollback) Push(line uv.Line, softWrapped bool) { if s == nil || s.maxLines <= 0 { return } @@ -58,16 +51,13 @@ func (s *Scrollback) Push(line uv.Line, wrapped bool) { // Clone the line content up to and including the last non-empty cell cloned := slices.Clone(line[:lastNonEmpty+1]) - entry := ScrollbackLine{ - Cells: cloned, - SoftWrapped: wrapped, - } - if len(s.lines) >= s.maxLines { // Remove oldest line and append new one s.lines = slices.Delete(s.lines, 0, 1) + s.wrapped = slices.Delete(s.wrapped, 0, 1) } - s.lines = append(s.lines, entry) + s.lines = append(s.lines, cloned) + s.wrapped = append(s.wrapped, softWrapped) } // Len returns the number of lines in the scrollback buffer. @@ -97,6 +87,7 @@ func (s *Scrollback) SetMaxLines(maxLines int) { if len(s.lines) > maxLines { // Remove oldest lines s.lines = s.lines[len(s.lines)-maxLines:] + s.wrapped = s.wrapped[len(s.wrapped)-maxLines:] } } @@ -107,17 +98,7 @@ func (s *Scrollback) Line(index int) uv.Line { if s == nil || index < 0 || index >= len(s.lines) { return nil } - return s.lines[index].Cells -} - -// LineEntry returns the full ScrollbackLine at the given index. -// Index 0 is the oldest line, Len()-1 is the most recent. -// Returns nil if index is out of bounds. -func (s *Scrollback) LineEntry(index int) *ScrollbackLine { - if s == nil || index < 0 || index >= len(s.lines) { - return nil - } - return &s.lines[index] + return s.lines[index] } // Lines returns all line cells in the scrollback buffer. @@ -126,11 +107,7 @@ func (s *Scrollback) Lines() []uv.Line { if s == nil { return nil } - result := make([]uv.Line, len(s.lines)) - for i, entry := range s.lines { - result[i] = entry.Cells - } - return result + return slices.Clone(s.lines) } // Clear removes all lines from the scrollback buffer. @@ -139,6 +116,7 @@ func (s *Scrollback) Clear() { return } s.lines = s.lines[:0] + s.wrapped = s.wrapped[:0] } // CellAt returns the cell at the given position in the scrollback buffer. @@ -151,19 +129,3 @@ func (s *Scrollback) CellAt(x, y int) *uv.Cell { } return &line[x] } - -// entries returns the raw slice of scrollback entries for iteration. -func (s *Scrollback) entries() []ScrollbackLine { - if s == nil { - return nil - } - return s.lines -} - -// replaceLines replaces all lines in the scrollback buffer. -func (s *Scrollback) replaceLines(lines []ScrollbackLine) { - if s == nil { - return - } - s.lines = lines -} From 59eda50b378da1689df68ce26721ba379618d1c8 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Mon, 9 Mar 2026 02:27:03 -0400 Subject: [PATCH 7/7] dont use reserved word cap --- vt/scrollback.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vt/scrollback.go b/vt/scrollback.go index 9a7f7b71..73f5b4be 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -21,10 +21,10 @@ func NewScrollback(maxLines int) *Scrollback { if maxLines <= 0 { maxLines = DefaultScrollbackSize } - cap := min(maxLines, 1000) // Pre-allocate reasonable capacity + capacity := min(maxLines, 1000) // Pre-allocate reasonable capacity return &Scrollback{ - lines: make([]uv.Line, 0, cap), - wrapped: make([]bool, 0, cap), + lines: make([]uv.Line, 0, capacity), + wrapped: make([]bool, 0, capacity), maxLines: maxLines, } }