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..58e3765b 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(uint8(i)) // #nosec G115 -- i is bounds-checked above (0-255) } return c diff --git a/vt/screen.go b/vt/screen.go index 04784f81..6151e62e 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" ) @@ -17,13 +19,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 +44,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 +81,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() } @@ -100,7 +114,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) } } } @@ -333,6 +348,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 } @@ -363,11 +389,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.pushLinesToScrollback(y, linesToSave) } 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 } @@ -391,6 +428,107 @@ 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) { + 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 { + if y >= 0 && y < len(s.softWrapped) { + return s.softWrapped[y] + } + 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. +// 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 + } + + sb := s.scrollback + n := sb.Len() + if n == 0 || newWidth <= 0 { + return + } + + // Collect all logical lines (joining wrapped physical lines) + var logicalLines []uv.Line + var current uv.Line + + for i := range n { + current = append(current, sb.lines[i]...) + if !sb.wrapped[i] { + // 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 := sb.MaxLines() + newLines := make([]uv.Line, 0, n) + newWrapped := make([]bool, 0, n) + + for _, logical := range logicalLines { + if len(logical) == 0 { + 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, 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, slices.Clone(logical)) + newWrapped = append(newWrapped, false) + if len(newLines) >= maxLines { + newLines = slices.Delete(newLines, 0, 1) + newWrapped = slices.Delete(newWrapped, 0, 1) + } + } + } + + sb.lines = newLines + sb.wrapped = newWrapped +} + // Scrollback returns the screen's scrollback buffer. func (s *Screen) Scrollback() *Scrollback { return s.scrollback @@ -410,3 +548,183 @@ 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) + var logicalLines []uv.Line + + // Track cursor position as offset within logical lines + cursorLogicalLine := -1 + cursorLogicalOffset := 0 + + // Process screen lines + var current uv.Line + 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, current) + current = nil + } + } + // Handle trailing wrapped line + if len(current) > 0 { + logicalLines = append(logicalLines, current) + } + + // 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 + for i, logical := range logicalLines { + if len(logical) > 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) == 0 { + newCursorX = 0 + newCursorY = writeY + cursorLogicalLine = -1 // Mark as found + writeY++ + continue + } + + if len(logical) == 0 { + // Empty logical line (but not cursor line) + writeY++ + continue + } + + cells := logical + 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 + newSoftWrapped[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 and softWrapped + s.buf = newBuf + s.softWrapped = newSoftWrapped + s.scroll = s.buf.Bounds() + + // Reflow scrollback if present + s.reflowScrollback(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..73f5b4be 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -12,6 +12,7 @@ const DefaultScrollbackSize = 10000 // Scrollback represents a scrollback buffer that stores lines scrolled off the screen. type Scrollback struct { lines []uv.Line + wrapped []bool maxLines int } @@ -20,15 +21,18 @@ func NewScrollback(maxLines int) *Scrollback { if maxLines <= 0 { maxLines = DefaultScrollbackSize } + capacity := min(maxLines, 1000) // Pre-allocate reasonable capacity return &Scrollback{ - lines: make([]uv.Line, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity + lines: make([]uv.Line, 0, capacity), + wrapped: make([]bool, 0, 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 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 } @@ -50,21 +54,10 @@ func (s *Scrollback) Push(line uv.Line) { 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, cloned) -} - -// PushN adds n lines from the buffer starting at line y to the scrollback. -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) - } - } + s.wrapped = append(s.wrapped, softWrapped) } // Len returns the number of lines in the scrollback buffer. @@ -94,10 +87,11 @@ 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:] } } -// 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 { @@ -107,13 +101,13 @@ func (s *Scrollback) Line(index int) uv.Line { 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 + return slices.Clone(s.lines) } // Clear removes all lines from the scrollback buffer. @@ -122,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. diff --git a/vt/scrollback_test.go b/vt/scrollback_test.go index de7fc28b..ced8a072 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,138 @@ 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) { + // 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) + 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 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()) + } + + // 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.