From 98a0fac2d7f9f39786e3d97a10d1f66ee9e387d4 Mon Sep 17 00:00:00 2001 From: Takehito Gondo <773366+takezoh@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:00:04 +0900 Subject: [PATCH 1/2] feat(vt): preserve semantic wraps across reattach --- vt/cc.go | 5 + vt/emulator.go | 25 ++--- vt/reattach.go | 27 +++++ vt/reattach_snapshot_test.go | 110 ++++++++++++++++++++ vt/reflow.go | 194 +++++++++++++++++++++++++++++++++++ vt/screen.go | 57 +++++++++- vt/scrollback.go | 85 ++++++++++++--- vt/utf8.go | 2 +- 8 files changed, 468 insertions(+), 37 deletions(-) create mode 100644 vt/reattach.go create mode 100644 vt/reattach_snapshot_test.go create mode 100644 vt/reflow.go diff --git a/vt/cc.go b/vt/cc.go index cf3287a6..a6fa9984 100644 --- a/vt/cc.go +++ b/vt/cc.go @@ -24,6 +24,10 @@ func (e *Emulator) linefeed() { // index moves the cursor down one line, scrolling up if necessary. This // always resets the phantom state i.e. pending wrap state. func (e *Emulator) index() { + e.indexWithWrap(false) +} + +func (e *Emulator) indexWithWrap(wrapped bool) { x, y := e.scr.CursorPosition() scroll := e.scr.ScrollRegion() // XXX: Handle scrollback whenever we add it. @@ -32,6 +36,7 @@ func (e *Emulator) index() { } else if y < scroll.Max.Y-1 || !uv.Pos(x, y).In(scroll) { e.scr.moveCursor(0, 1) } + e.scr.setCurrentRowWrapped(wrapped) e.atPhantom = false } diff --git a/vt/emulator.go b/vt/emulator.go index b67cce41..5647f4af 100644 --- a/vt/emulator.go +++ b/vt/emulator.go @@ -215,32 +215,21 @@ func (e *Emulator) CursorPosition() uv.Position { // Resize resizes the terminal. func (e *Emulator) Resize(width int, height int) { - x, y := e.scr.CursorPosition() + wasPhantom := e.atPhantom if e.atPhantom { - if x < width-1 { - e.atPhantom = false - x++ - } - } - - if y < 0 { - y = 0 - } - if y >= height { - y = height - 1 - } - if x < 0 { - x = 0 - } - if x >= width { - x = width - 1 + // The cursor is logically one column after the final cell while the + // terminal is in pending-wrap state. Expose that offset to semantic + // reflow; Screen.resizeReflow maps it to the new physical row. + e.scr.cur.X++ } e.scrs[0].Resize(width, height) e.scrs[1].Resize(width, height) e.tabstops = uv.DefaultTabStops(width) + x, y := e.scr.CursorPosition() e.setCursor(x, y) + e.atPhantom = wasPhantom && x >= width-1 if e.isModeSet(ansi.ModeInBandResize) { _, _ = io.WriteString(e.pw, ansi.InBandResize(e.Height(), e.Width(), 0, 0)) diff --git a/vt/reattach.go b/vt/reattach.go new file mode 100644 index 00000000..6c84177a --- /dev/null +++ b/vt/reattach.go @@ -0,0 +1,27 @@ +package vt + +import ( + "bytes" + "fmt" +) + +// ReattachSnapshot renders retained terminal state as xterm-compatible ANSI. +// A hard boundary is emitted as CRLF. A soft-wrap continuation is emitted +// without a line break so the receiving terminal creates an isWrapped row at +// its own width and can reflow it on later resizes. +func (e *Emulator) ReattachSnapshot() []byte { + includeScrollback := !e.IsAltScreen() + rows := e.scr.snapshotRows(includeScrollback) + + var buf bytes.Buffer + for i, row := range rows { + buf.WriteString(row.Line.Render()) + if i+1 < len(rows) && !rows[i+1].Wrapped { + buf.WriteString("\r\n") + } + } + + x, y := e.scr.CursorPosition() + _, _ = fmt.Fprintf(&buf, "\x1b[%d;%dH\x1b[K", y+1, x+1) + return buf.Bytes() +} diff --git a/vt/reattach_snapshot_test.go b/vt/reattach_snapshot_test.go new file mode 100644 index 00000000..45f2c74f --- /dev/null +++ b/vt/reattach_snapshot_test.go @@ -0,0 +1,110 @@ +package vt + +import ( + "strings" + "testing" +) + +func TestReattachSnapshotPreservesSoftAndHardBoundaries(t *testing.T) { + t.Run("terminal autowrap stays soft", func(t *testing.T) { + e := NewEmulator(5, 3) + e.WriteString("abcdefghij") + + got := string(e.ReattachSnapshot()) + if !strings.Contains(got, "abcdefghij") { + t.Fatalf("snapshot = %q, want contiguous soft-wrapped text", got) + } + if strings.Contains(got, "abcde\nfghij") || strings.Contains(got, "abcde\r\nfghij") { + t.Fatalf("snapshot = %q, soft wrap was serialized as a hard break", got) + } + }) + + t.Run("application newline stays hard", func(t *testing.T) { + e := NewEmulator(5, 3) + e.WriteString("abcde\r\nfghij") + + got := string(e.ReattachSnapshot()) + if !strings.Contains(got, "abcde\r\nfghij") { + t.Fatalf("snapshot = %q, want application newline to remain hard", got) + } + }) +} + +func TestResizeReflowsPrimaryHistoryAndCursor(t *testing.T) { + e := NewEmulator(5, 2) + e.WriteString("abcdefghijk") + + if e.ScrollbackLen() == 0 { + t.Fatal("test setup did not create scrollback") + } + + e.Resize(10, 2) + + if got := e.ScrollbackLen(); got != 0 { + t.Fatalf("scrollback len after widening = %d, want 0", got) + } + if got := e.Render(); !strings.Contains(got, "abcdefghij\nk") { + t.Fatalf("render after widening = %q, want reflowed primary history", got) + } + if got := e.CursorPosition(); got.X != 1 || got.Y != 1 { + t.Fatalf("cursor after widening = %v, want (1,1)", got) + } + + e.Resize(5, 3) + got := string(e.ReattachSnapshot()) + if !strings.Contains(got, "abcdefghijk") { + t.Fatalf("snapshot after shrink = %q, want logical text preserved", got) + } +} + +func TestResizePreservesHardBoundariesAndUnicodeCells(t *testing.T) { + e := NewEmulator(6, 3) + e.WriteString("ab界e\u0301z\r\nsecond") + + e.Resize(12, 3) + got := string(e.ReattachSnapshot()) + if !strings.Contains(got, "ab界e\u0301z\r\nsecond") { + t.Fatalf("snapshot after widening = %q, want Unicode cells and hard newline preserved", got) + } + + e.Resize(4, 4) + got = string(e.ReattachSnapshot()) + if !strings.Contains(got, "ab界e\u0301z") { + t.Fatalf("snapshot after narrowing = %q, want Unicode logical row preserved", got) + } + if !strings.Contains(got, "\r\nsecond") { + t.Fatalf("snapshot after narrowing = %q, want hard newline preserved", got) + } +} + +func TestScrollbackCapMarksTruncatedSoftWrapHead(t *testing.T) { + e := NewEmulator(5, 1) + e.SetScrollbackSize(1) + e.WriteString("abcdefghijk") + + rows := e.scr.scrollback.Rows() + if len(rows) != 1 { + t.Fatalf("scrollback rows = %d, want 1", len(rows)) + } + if rows[0].Wrapped || !rows[0].HeadTruncated { + t.Fatalf("retained head boundary = {Wrapped:%v HeadTruncated:%v}, want hard truncated head", rows[0].Wrapped, rows[0].HeadTruncated) + } + got := string(e.ReattachSnapshot()) + if strings.Contains(got, "abcde") || !strings.Contains(got, "fghijk") { + t.Fatalf("snapshot = %q, want only retained logical suffix", got) + } +} + +func TestResizePreservesExactColumnPendingWrap(t *testing.T) { + e := NewEmulator(5, 2) + e.WriteString("abcdefghij") + e.Resize(10, 2) + e.WriteString("k") + + if got := string(e.ReattachSnapshot()); !strings.Contains(got, "abcdefghijk") { + t.Fatalf("snapshot = %q, want pending-wrap continuation after widening", got) + } + if got := e.CursorPosition(); got.X != 1 || got.Y != 1 { + t.Fatalf("cursor = %v, want (1,1)", got) + } +} diff --git a/vt/reflow.go b/vt/reflow.go new file mode 100644 index 00000000..3edf3ee1 --- /dev/null +++ b/vt/reflow.go @@ -0,0 +1,194 @@ +package vt + +import ( + "slices" + + uv "github.com/charmbracelet/ultraviolet" +) + +type semanticRow struct { + line uv.Line + wrapped bool + headTruncated bool +} + +type logicalRows struct { + cells uv.Line + headTruncated bool + hasCursor bool + cursorOffset int +} + +func (s *Screen) resizeReflow(width, height int) { + if width <= 0 || height <= 0 { + return + } + + rows, cursorRow := s.semanticRows() + groups := groupSemanticRows(rows, cursorRow, s.cur.X) + + var ( + reflowed []ScrollbackRow + cursorGlobalY int + cursorX int + cursorWasFound bool + ) + for _, group := range groups { + packed, cy, cx, found := packLogicalRows(group, width) + if found { + cursorGlobalY = len(reflowed) + cy + cursorX = cx + cursorWasFound = true + } + reflowed = append(reflowed, packed...) + } + + for len(reflowed) < height { + reflowed = append(reflowed, ScrollbackRow{Line: uv.NewLine(width)}) + } + screenStart := max(0, len(reflowed)-height) + if s.scrollback != nil { + s.scrollback.ReplaceRows(reflowed[:screenStart]) + } + + newbuf := uv.NewRenderBuffer(width, height) + newWrapped := make([]bool, height) + for y, row := range reflowed[screenStart:] { + copy(newbuf.Line(y), row.Line) + newWrapped[y] = row.Wrapped + } + newbuf.Touched = nil + s.buf = newbuf + s.wrapped = newWrapped + + if cursorWasFound { + s.cur.X = min(max(cursorX, 0), width-1) + s.cur.Y = min(max(cursorGlobalY-screenStart, 0), height-1) + } else { + s.cur.X = min(max(s.cur.X, 0), width-1) + s.cur.Y = min(max(s.cur.Y, 0), height-1) + } + s.saved.X = min(max(s.saved.X, 0), width-1) + s.saved.Y = min(max(s.saved.Y, 0), height-1) +} + +func (s *Screen) semanticRows() ([]semanticRow, int) { + scrollbackLen := 0 + if s.scrollback != nil { + scrollbackLen = s.scrollback.Len() + } + rows := make([]semanticRow, 0, s.buf.Height()+scrollbackLen) + if s.scrollback != nil { + for _, row := range s.scrollback.Rows() { + rows = append(rows, semanticRow{ + line: slices.Clone(row.Line), + wrapped: row.Wrapped, + headTruncated: row.HeadTruncated, + }) + } + } + cursorRow := len(rows) + s.cur.Y + for y := 0; y < s.buf.Height(); y++ { + rows = append(rows, semanticRow{ + line: slices.Clone(s.buf.Line(y)), + wrapped: s.rowWrapped(y), + }) + } + return rows, cursorRow +} + +func groupSemanticRows(rows []semanticRow, cursorRow, cursorX int) []logicalRows { + groups := make([]logicalRows, 0, len(rows)) + for i, row := range rows { + if len(groups) == 0 || !row.wrapped { + groups = append(groups, logicalRows{headTruncated: row.headTruncated}) + } + group := &groups[len(groups)-1] + take := semanticLineLength(row.line) + if i+1 < len(rows) && rows[i+1].wrapped { + take = len(row.line) + } + if i == cursorRow { + take = max(take, min(cursorX, len(row.line))) + group.hasCursor = true + group.cursorOffset = len(group.cells) + min(cursorX, take) + } + group.cells = append(group.cells, row.line[:take]...) + } + return groups +} + +func semanticLineLength(line uv.Line) int { + for i := len(line) - 1; i >= 0; i-- { + cell := line[i] + if !cell.IsZero() && !cell.Equal(&uv.EmptyCell) { + return min(len(line), i+max(cell.Width, 1)) + } + } + return 0 +} + +func packLogicalRows(group logicalRows, width int) ([]ScrollbackRow, int, int, bool) { + rows := []ScrollbackRow{{ + Line: uv.NewLine(width), + HeadTruncated: group.headTruncated, + }} + y, x := 0, 0 + cursorY, cursorX := 0, 0 + cursorMapped := false + + for i := 0; i < len(group.cells); { + cell := group.cells[i] + cellWidth := cell.Width + if cellWidth <= 0 { + if cell.Content == "" { + i++ + continue + } + cellWidth = 1 + } + if x > 0 && x+cellWidth > width { + y++ + x = 0 + rows = append(rows, ScrollbackRow{Line: uv.NewLine(width), Wrapped: true}) + } + if group.hasCursor && !cursorMapped && group.cursorOffset >= i && group.cursorOffset < i+cellWidth { + cursorY = y + cursorX = min(x+group.cursorOffset-i, width-1) + cursorMapped = true + } + rows[y].Line.Set(x, &cell) + x += cellWidth + i += cellWidth + if x >= width && i < len(group.cells) { + y++ + x = 0 + rows = append(rows, ScrollbackRow{Line: uv.NewLine(width), Wrapped: true}) + } + } + + if group.hasCursor && !cursorMapped { + cursorY = y + cursorX = min(x, width-1) + cursorMapped = true + } + return rows, cursorY, cursorX, cursorMapped +} + +func (s *Screen) snapshotRows(includeScrollback bool) []ScrollbackRow { + scrollbackLen := 0 + if s.scrollback != nil { + scrollbackLen = s.scrollback.Len() + } + rows := make([]ScrollbackRow, 0, s.buf.Height()+scrollbackLen) + if includeScrollback && s.scrollback != nil { + rows = append(rows, s.scrollback.Rows()...) + } + for y := 0; y < s.buf.Height(); y++ { + rows = append(rows, ScrollbackRow{ + Line: s.buf.Line(y), + Wrapped: s.rowWrapped(y), + }) + } + return rows +} diff --git a/vt/screen.go b/vt/screen.go index 04784f81..4574befb 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -11,6 +11,9 @@ type Screen struct { cb *Callbacks // The buffer of the screen. buf *uv.RenderBuffer + // wrapped records whether each physical row continues a terminal + // autowrap from the previous row. It is kept parallel to buf rows. + wrapped []bool // The cur of the screen. cur, saved Cursor // scroll is the scroll region. @@ -23,6 +26,7 @@ type Screen struct { func NewScreen(w, h int) *Screen { s := Screen{ buf: uv.NewRenderBuffer(w, h), + wrapped: make([]bool, h), scrollback: NewScrollback(DefaultScrollbackSize), } s.scroll = s.buf.Bounds() @@ -34,6 +38,7 @@ func NewScreen(w, h int) *Screen { // cursor styles, and resets the scroll region. func (s *Screen) Reset() { s.buf.Clear() + clear(s.wrapped) s.cur = Cursor{} s.saved = Cursor{} s.scroll = s.buf.Bounds() @@ -74,9 +79,9 @@ func (s *Screen) Height() int { func (s *Screen) Resize(width int, height int) { if s.buf == nil { s.buf = uv.NewRenderBuffer(width, height) + s.wrapped = make([]bool, height) } else { - s.buf.Resize(width, height) - s.buf.Touched = nil + s.resizeReflow(width, height) } s.scroll = s.buf.Bounds() } @@ -89,6 +94,7 @@ func (s *Screen) Width() int { // Clear clears the screen with blank cells. func (s *Screen) Clear() { s.ClearArea(s.Bounds()) + clear(s.wrapped) } // ClearWithScrollback saves all non-empty lines to scrollback before clearing. @@ -100,7 +106,7 @@ 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) + s.scrollback.PushWrapped(line, s.rowWrapped(y)) } } } @@ -332,6 +338,7 @@ func (s *Screen) InsertLine(n int) bool { } s.buf.InsertLineArea(y, n, s.blankCell(), s.scroll) + s.insertWrappedRows(y, n, s.scroll) return true } @@ -363,10 +370,11 @@ 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, s.wrapped, y, linesToSave) } s.buf.DeleteLineArea(y, n, s.blankCell(), scroll) + s.deleteWrappedRows(y, n, scroll) return true } @@ -410,3 +418,44 @@ func (s *Screen) SetScrollbackSize(maxLines int) { s.scrollback.SetMaxLines(maxLines) } } + +// setCurrentRowWrapped records whether the cursor row begins as an autowrap +// continuation. Explicit line transitions call this with false. +func (s *Screen) setCurrentRowWrapped(wrapped bool) { + _, y := s.CursorPosition() + if y >= 0 && y < len(s.wrapped) { + s.wrapped[y] = wrapped + } +} + +func (s *Screen) rowWrapped(y int) bool { + return y >= 0 && y < len(s.wrapped) && s.wrapped[y] +} + +func (s *Screen) insertWrappedRows(y, n int, area uv.Rectangle) { + maxY := min(area.Max.Y, len(s.wrapped)) + n = min(n, maxY-y) + if n <= 0 || y < 0 || y >= maxY { + return + } + if area.Min.X != 0 || area.Max.X != s.buf.Width() { + clear(s.wrapped[y:maxY]) + return + } + copy(s.wrapped[y+n:maxY], s.wrapped[y:maxY-n]) + clear(s.wrapped[y : y+n]) +} + +func (s *Screen) deleteWrappedRows(y, n int, area uv.Rectangle) { + maxY := min(area.Max.Y, len(s.wrapped)) + n = min(n, maxY-y) + if n <= 0 || y < 0 || y >= maxY { + return + } + if area.Min.X != 0 || area.Max.X != s.buf.Width() { + clear(s.wrapped[y:maxY]) + return + } + copy(s.wrapped[y:maxY-n], s.wrapped[y+n:maxY]) + clear(s.wrapped[maxY-n : maxY]) +} diff --git a/vt/scrollback.go b/vt/scrollback.go index 59d03a03..ea62c1c4 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -11,17 +11,27 @@ const DefaultScrollbackSize = 10000 // Scrollback represents a scrollback buffer that stores lines scrolled off the screen. type Scrollback struct { - lines []uv.Line + rows []ScrollbackRow maxLines int } +// ScrollbackRow is a physical terminal row plus the semantic boundary that +// precedes it. Wrapped is true only when the row continues a terminal +// autowrap from the previous retained row. HeadTruncated marks a retained +// fragment whose preceding soft-wrapped rows were evicted by the cap. +type ScrollbackRow struct { + Line uv.Line + Wrapped bool + HeadTruncated bool +} + // NewScrollback creates a new scrollback buffer with the given maximum number of lines. func NewScrollback(maxLines int) *Scrollback { if maxLines <= 0 { maxLines = DefaultScrollbackSize } return &Scrollback{ - lines: make([]uv.Line, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity + rows: make([]ScrollbackRow, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity maxLines: maxLines, } } @@ -29,6 +39,12 @@ func NewScrollback(maxLines int) *Scrollback { // 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) { + s.PushWrapped(line, false) +} + +// PushWrapped adds a row while preserving whether it is a soft-wrap +// continuation of the previous physical row. +func (s *Scrollback) PushWrapped(line uv.Line, wrapped bool) { if s == nil || s.maxLines <= 0 { return } @@ -47,22 +63,32 @@ 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]) - if len(s.lines) >= s.maxLines { + evicted := len(s.rows) >= s.maxLines + if evicted { // Remove oldest line and append new one - s.lines = slices.Delete(s.lines, 0, 1) + s.rows = slices.Delete(s.rows, 0, 1) + if len(s.rows) > 0 && s.rows[0].Wrapped { + s.rows[0].Wrapped = false + s.rows[0].HeadTruncated = true + } + } + s.rows = append(s.rows, ScrollbackRow{Line: cloned, Wrapped: wrapped}) + if evicted && len(s.rows) > 0 && s.rows[0].Wrapped { + s.rows[0].Wrapped = false + s.rows[0].HeadTruncated = true } - 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) { +func (s *Scrollback) PushN(buf *uv.RenderBuffer, wrapped []bool, 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) + isWrapped := y+i < len(wrapped) && wrapped[y+i] + s.PushWrapped(line, isWrapped) } } } @@ -72,7 +98,7 @@ func (s *Scrollback) Len() int { if s == nil { return 0 } - return len(s.lines) + return len(s.rows) } // MaxLines returns the maximum number of lines the scrollback buffer can hold. @@ -91,9 +117,13 @@ func (s *Scrollback) SetMaxLines(maxLines int) { } s.maxLines = maxLines - if len(s.lines) > maxLines { + if len(s.rows) > maxLines { // Remove oldest lines - s.lines = s.lines[len(s.lines)-maxLines:] + s.rows = s.rows[len(s.rows)-maxLines:] + if len(s.rows) > 0 && s.rows[0].Wrapped { + s.rows[0].Wrapped = false + s.rows[0].HeadTruncated = true + } } } @@ -101,10 +131,10 @@ func (s *Scrollback) SetMaxLines(maxLines int) { // 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) { + if s == nil || index < 0 || index >= len(s.rows) { return nil } - return s.lines[index] + return s.rows[index].Line } // Lines returns all lines in the scrollback buffer. @@ -113,7 +143,34 @@ func (s *Scrollback) Lines() []uv.Line { if s == nil { return nil } - return s.lines + lines := make([]uv.Line, len(s.rows)) + for i := range s.rows { + lines[i] = s.rows[i].Line + } + return lines +} + +// Rows returns the retained rows and their boundary provenance. +func (s *Scrollback) Rows() []ScrollbackRow { + if s == nil { + return nil + } + return s.rows +} + +// ReplaceRows atomically replaces retained history after a semantic reflow. +func (s *Scrollback) ReplaceRows(rows []ScrollbackRow) { + if s == nil { + return + } + if len(rows) > s.maxLines { + rows = rows[len(rows)-s.maxLines:] + if len(rows) > 0 && rows[0].Wrapped { + rows[0].Wrapped = false + rows[0].HeadTruncated = true + } + } + s.rows = slices.Clone(rows) } // Clear removes all lines from the scrollback buffer. @@ -121,7 +178,7 @@ func (s *Scrollback) Clear() { if s == nil { return } - s.lines = s.lines[:0] + s.rows = s.rows[:0] } // CellAt returns the cell at the given position in the scrollback buffer. diff --git a/vt/utf8.go b/vt/utf8.go index d04c2a70..6925af67 100644 --- a/vt/utf8.go +++ b/vt/utf8.go @@ -55,7 +55,7 @@ func (e *Emulator) handleGrapheme(content string, width int) { // 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. - e.index() + e.indexWithWrap(true) _, y = e.scr.CursorPosition() x = 0 } From 7ecef7c0bc30934a583ca13b35f9828433d8d0fc Mon Sep 17 00:00:00 2001 From: Takehito Gondo <773366+takezoh@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:04:22 +0900 Subject: [PATCH 2/2] fix(vt): unify semantic row ownership Keep cells and row boundaries behind one mutation owner so erase, scroll, resize, and snapshot cannot observe stale parallel provenance. Separate alternate physical resize from primary reflow and make snapshots atomic and fallible. --- vt/api_compat_test.go | 58 +++++++ vt/emulator.go | 9 +- vt/handlers.go | 4 + vt/reattach.go | 23 ++- vt/reattach_snapshot_test.go | 29 ++-- vt/reflow.go | 190 +++++++++++---------- vt/safe_emulator.go | 7 + vt/screen.go | 130 ++++++--------- vt/scrollback.go | 156 ++++++++---------- vt/semantic_buffer.go | 311 +++++++++++++++++++++++++++++++++++ vt/semantic_contract_test.go | 165 +++++++++++++++++++ 11 files changed, 814 insertions(+), 268 deletions(-) create mode 100644 vt/api_compat_test.go create mode 100644 vt/semantic_buffer.go create mode 100644 vt/semantic_contract_test.go diff --git a/vt/api_compat_test.go b/vt/api_compat_test.go new file mode 100644 index 00000000..8f361271 --- /dev/null +++ b/vt/api_compat_test.go @@ -0,0 +1,58 @@ +package vt_test + +import ( + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/vt" +) + +// These compile-time fixtures pin the exported Screen and Scrollback method +// signatures that existed before semantic reflow was introduced. +type preReflowScrollback interface { + Push(uv.Line) + PushN(*uv.RenderBuffer, int, int) + Len() int + MaxLines() int + SetMaxLines(int) + Line(int) uv.Line + Lines() []uv.Line + Clear() + CellAt(int, int) *uv.Cell +} + +type preReflowScreen interface { + Reset() + Bounds() uv.Rectangle + Touched() []*uv.LineData + ClearTouched() + CellAt(int, int) *uv.Cell + SetCell(int, int, *uv.Cell) + Height() int + Resize(int, int) + Width() int + Clear() + ClearWithScrollback() + ClearArea(uv.Rectangle) + Fill(*uv.Cell) + FillArea(*uv.Cell, uv.Rectangle) + Cursor() vt.Cursor + CursorPosition() (int, int) + ScrollRegion() uv.Rectangle + SaveCursor() + RestoreCursor() + ShowCursor() + HideCursor() + InsertCell(int) + DeleteCell(int) + ScrollUp(int) + ScrollDown(int) + InsertLine(int) bool + DeleteLine(int) bool + Scrollback() *vt.Scrollback + SetScrollback(*vt.Scrollback) + SetScrollbackSize(int) +} + +var ( + _ preReflowScrollback = (*vt.Scrollback)(nil) + _ preReflowScreen = (*vt.Screen)(nil) +) diff --git a/vt/emulator.go b/vt/emulator.go index 5647f4af..1965d93a 100644 --- a/vt/emulator.go +++ b/vt/emulator.go @@ -82,6 +82,7 @@ func NewEmulator(w, h int) *Emulator { t := new(Emulator) t.scrs[0] = *NewScreen(w, h) t.scrs[1] = *NewScreen(w, h) + t.scrs[1].SetScrollback(nil) t.scr = &t.scrs[0] t.scrs[0].cb = &t.cb t.scrs[1].cb = &t.cb @@ -131,14 +132,14 @@ func (e *Emulator) Touched() []*uv.LineData { // String returns a string representation of the underlying screen buffer. func (e *Emulator) String() string { - s := e.scr.buf.String() + s := e.scr.buf.string() return uv.TrimSpace(s) } // Render renders a snapshot of the terminal screen as a string with styles and // links encoded as ANSI escape codes. func (e *Emulator) Render() string { - return e.scr.buf.Render() + return e.scr.buf.render() } var _ uv.Screen = (*Emulator)(nil) @@ -224,8 +225,8 @@ func (e *Emulator) Resize(width int, height int) { } e.scrs[0].Resize(width, height) - e.scrs[1].Resize(width, height) - e.tabstops = uv.DefaultTabStops(width) + e.scrs[1].resizePhysical(width, height) + e.tabstops.Resize(width) x, y := e.scr.CursorPosition() e.setCursor(x, y) diff --git a/vt/handlers.go b/vt/handlers.go index ca7ff54f..62b78335 100644 --- a/vt/handlers.go +++ b/vt/handlers.go @@ -461,6 +461,7 @@ func (e *Emulator) registerDefaultCsiHandlers() { // Insert Character [ansi.ICH] n, _, _ := params.Param(0, 1) e.scr.InsertCell(n) + e.atPhantom = false return true }) @@ -600,6 +601,7 @@ func (e *Emulator) registerDefaultCsiHandlers() { // Move the cursor to the left margin. e.scr.setCursorX(0, true) } + e.atPhantom = false return true }) @@ -612,6 +614,7 @@ func (e *Emulator) registerDefaultCsiHandlers() { // Move the cursor to the left margin. e.scr.setCursorX(0, true) } + e.atPhantom = false return true }) @@ -619,6 +622,7 @@ func (e *Emulator) registerDefaultCsiHandlers() { // Delete Character [ansi.DCH] n, _, _ := params.Param(0, 1) e.scr.DeleteCell(n) + e.atPhantom = false return true }) diff --git a/vt/reattach.go b/vt/reattach.go index 6c84177a..828bf802 100644 --- a/vt/reattach.go +++ b/vt/reattach.go @@ -5,23 +5,36 @@ import ( "fmt" ) +// SnapshotError reports an invalid semantic terminal state. A failed snapshot +// never contains partial ANSI bytes. +type SnapshotError struct { + Cause error +} + +func (e *SnapshotError) Error() string { return "vt reattach snapshot: " + e.Cause.Error() } + +func (e *SnapshotError) Unwrap() error { return e.Cause } + // ReattachSnapshot renders retained terminal state as xterm-compatible ANSI. // A hard boundary is emitted as CRLF. A soft-wrap continuation is emitted // without a line break so the receiving terminal creates an isWrapped row at // its own width and can reflow it on later resizes. -func (e *Emulator) ReattachSnapshot() []byte { +func (e *Emulator) ReattachSnapshot() ([]byte, error) { includeScrollback := !e.IsAltScreen() - rows := e.scr.snapshotRows(includeScrollback) + rows, err := e.scr.snapshotRows(includeScrollback) + if err != nil { + return nil, &SnapshotError{Cause: err} + } var buf bytes.Buffer for i, row := range rows { - buf.WriteString(row.Line.Render()) - if i+1 < len(rows) && !rows[i+1].Wrapped { + buf.WriteString(row.line.Render()) + if i+1 < len(rows) && rows[i+1].boundary != boundarySoft { buf.WriteString("\r\n") } } x, y := e.scr.CursorPosition() _, _ = fmt.Fprintf(&buf, "\x1b[%d;%dH\x1b[K", y+1, x+1) - return buf.Bytes() + return buf.Bytes(), nil } diff --git a/vt/reattach_snapshot_test.go b/vt/reattach_snapshot_test.go index 45f2c74f..b48efd8b 100644 --- a/vt/reattach_snapshot_test.go +++ b/vt/reattach_snapshot_test.go @@ -5,12 +5,21 @@ import ( "testing" ) +func snapshotString(t *testing.T, e *Emulator) string { + t.Helper() + snapshot, err := e.ReattachSnapshot() + if err != nil { + t.Fatalf("ReattachSnapshot() error = %v", err) + } + return string(snapshot) +} + func TestReattachSnapshotPreservesSoftAndHardBoundaries(t *testing.T) { t.Run("terminal autowrap stays soft", func(t *testing.T) { e := NewEmulator(5, 3) e.WriteString("abcdefghij") - got := string(e.ReattachSnapshot()) + got := snapshotString(t, e) if !strings.Contains(got, "abcdefghij") { t.Fatalf("snapshot = %q, want contiguous soft-wrapped text", got) } @@ -23,7 +32,7 @@ func TestReattachSnapshotPreservesSoftAndHardBoundaries(t *testing.T) { e := NewEmulator(5, 3) e.WriteString("abcde\r\nfghij") - got := string(e.ReattachSnapshot()) + got := snapshotString(t, e) if !strings.Contains(got, "abcde\r\nfghij") { t.Fatalf("snapshot = %q, want application newline to remain hard", got) } @@ -51,7 +60,7 @@ func TestResizeReflowsPrimaryHistoryAndCursor(t *testing.T) { } e.Resize(5, 3) - got := string(e.ReattachSnapshot()) + got := snapshotString(t, e) if !strings.Contains(got, "abcdefghijk") { t.Fatalf("snapshot after shrink = %q, want logical text preserved", got) } @@ -62,13 +71,13 @@ func TestResizePreservesHardBoundariesAndUnicodeCells(t *testing.T) { e.WriteString("ab界e\u0301z\r\nsecond") e.Resize(12, 3) - got := string(e.ReattachSnapshot()) + got := snapshotString(t, e) if !strings.Contains(got, "ab界e\u0301z\r\nsecond") { t.Fatalf("snapshot after widening = %q, want Unicode cells and hard newline preserved", got) } e.Resize(4, 4) - got = string(e.ReattachSnapshot()) + got = snapshotString(t, e) if !strings.Contains(got, "ab界e\u0301z") { t.Fatalf("snapshot after narrowing = %q, want Unicode logical row preserved", got) } @@ -82,14 +91,14 @@ func TestScrollbackCapMarksTruncatedSoftWrapHead(t *testing.T) { e.SetScrollbackSize(1) e.WriteString("abcdefghijk") - rows := e.scr.scrollback.Rows() + rows := e.scr.scrollback.semanticRows() if len(rows) != 1 { t.Fatalf("scrollback rows = %d, want 1", len(rows)) } - if rows[0].Wrapped || !rows[0].HeadTruncated { - t.Fatalf("retained head boundary = {Wrapped:%v HeadTruncated:%v}, want hard truncated head", rows[0].Wrapped, rows[0].HeadTruncated) + if rows[0].boundary != boundaryTruncatedHead { + t.Fatalf("retained head boundary = %v, want truncated head", rows[0].boundary) } - got := string(e.ReattachSnapshot()) + got := snapshotString(t, e) if strings.Contains(got, "abcde") || !strings.Contains(got, "fghijk") { t.Fatalf("snapshot = %q, want only retained logical suffix", got) } @@ -101,7 +110,7 @@ func TestResizePreservesExactColumnPendingWrap(t *testing.T) { e.Resize(10, 2) e.WriteString("k") - if got := string(e.ReattachSnapshot()); !strings.Contains(got, "abcdefghijk") { + if got := snapshotString(t, e); !strings.Contains(got, "abcdefghijk") { t.Fatalf("snapshot = %q, want pending-wrap continuation after widening", got) } if got := e.CursorPosition(); got.X != 1 || got.Y != 1 { diff --git a/vt/reflow.go b/vt/reflow.go index 3edf3ee1..d6376812 100644 --- a/vt/reflow.go +++ b/vt/reflow.go @@ -1,22 +1,29 @@ package vt import ( + "fmt" "slices" uv "github.com/charmbracelet/ultraviolet" ) -type semanticRow struct { - line uv.Line - wrapped bool - headTruncated bool +type logicalRows struct { + cells uv.Line + headBoundary rowBoundary + hasCursor bool + cursorOffset int + hasSaved bool + savedOffset int } -type logicalRows struct { - cells uv.Line - headTruncated bool - hasCursor bool - cursorOffset int +type mappedPosition struct { + x, y int + found bool +} + +type packedLogicalRows struct { + rows []semanticRow + cursor, saved mappedPosition } func (s *Screen) resizeReflow(width, height int) { @@ -24,42 +31,47 @@ func (s *Screen) resizeReflow(width, height int) { return } - rows, cursorRow := s.semanticRows() - groups := groupSemanticRows(rows, cursorRow, s.cur.X) + rows, screenStartBefore := s.semanticRows() + groups := groupSemanticRows( + rows, + screenStartBefore+s.cur.Y, + s.cur.X, + screenStartBefore+s.saved.Y, + s.saved.X, + ) var ( - reflowed []ScrollbackRow + reflowed []semanticRow cursorGlobalY int cursorX int cursorWasFound bool + savedGlobalY int + savedX int + savedWasFound bool ) for _, group := range groups { - packed, cy, cx, found := packLogicalRows(group, width) - if found { - cursorGlobalY = len(reflowed) + cy - cursorX = cx + packed := packLogicalRows(group, width) + if packed.cursor.found { + cursorGlobalY = len(reflowed) + packed.cursor.y + cursorX = packed.cursor.x cursorWasFound = true } - reflowed = append(reflowed, packed...) + if packed.saved.found { + savedGlobalY = len(reflowed) + packed.saved.y + savedX = packed.saved.x + savedWasFound = true + } + reflowed = append(reflowed, packed.rows...) } for len(reflowed) < height { - reflowed = append(reflowed, ScrollbackRow{Line: uv.NewLine(width)}) + reflowed = append(reflowed, semanticRow{line: uv.NewLine(width), boundary: boundaryHard}) } screenStart := max(0, len(reflowed)-height) if s.scrollback != nil { - s.scrollback.ReplaceRows(reflowed[:screenStart]) + s.scrollback.replaceSemanticRows(reflowed[:screenStart]) } - - newbuf := uv.NewRenderBuffer(width, height) - newWrapped := make([]bool, height) - for y, row := range reflowed[screenStart:] { - copy(newbuf.Line(y), row.Line) - newWrapped[y] = row.Wrapped - } - newbuf.Touched = nil - s.buf = newbuf - s.wrapped = newWrapped + s.buf.replace(width, height, reflowed[screenStart:]) if cursorWasFound { s.cur.X = min(max(cursorX, 0), width-1) @@ -68,44 +80,46 @@ func (s *Screen) resizeReflow(width, height int) { s.cur.X = min(max(s.cur.X, 0), width-1) s.cur.Y = min(max(s.cur.Y, 0), height-1) } + if savedWasFound { + s.saved.X = min(max(savedX, 0), width-1) + s.saved.Y = min(max(savedGlobalY-screenStart, 0), height-1) + } else { + s.saved.X = min(max(s.saved.X, 0), width-1) + s.saved.Y = min(max(s.saved.Y, 0), height-1) + } +} + +func (s *Screen) resizePhysical(width, height int) { + if width <= 0 || height <= 0 { + return + } + shift := s.buf.resizePhysical(width, height, s.cur.Y) + s.cur.X = min(max(s.cur.X, 0), width-1) + s.cur.Y = min(max(s.cur.Y+shift, 0), height-1) s.saved.X = min(max(s.saved.X, 0), width-1) - s.saved.Y = min(max(s.saved.Y, 0), height-1) + s.saved.Y = min(max(s.saved.Y+shift, 0), height-1) + s.scroll = s.buf.bounds() } func (s *Screen) semanticRows() ([]semanticRow, int) { - scrollbackLen := 0 + var rows []semanticRow if s.scrollback != nil { - scrollbackLen = s.scrollback.Len() + rows = s.scrollback.semanticRows() } - rows := make([]semanticRow, 0, s.buf.Height()+scrollbackLen) - if s.scrollback != nil { - for _, row := range s.scrollback.Rows() { - rows = append(rows, semanticRow{ - line: slices.Clone(row.Line), - wrapped: row.Wrapped, - headTruncated: row.HeadTruncated, - }) - } - } - cursorRow := len(rows) + s.cur.Y - for y := 0; y < s.buf.Height(); y++ { - rows = append(rows, semanticRow{ - line: slices.Clone(s.buf.Line(y)), - wrapped: s.rowWrapped(y), - }) - } - return rows, cursorRow + screenStart := len(rows) + rows = append(rows, s.buf.allRows()...) + return rows, screenStart } -func groupSemanticRows(rows []semanticRow, cursorRow, cursorX int) []logicalRows { +func groupSemanticRows(rows []semanticRow, cursorRow, cursorX, savedRow, savedX int) []logicalRows { groups := make([]logicalRows, 0, len(rows)) for i, row := range rows { - if len(groups) == 0 || !row.wrapped { - groups = append(groups, logicalRows{headTruncated: row.headTruncated}) + if len(groups) == 0 || row.boundary != boundarySoft { + groups = append(groups, logicalRows{headBoundary: row.boundary}) } group := &groups[len(groups)-1] take := semanticLineLength(row.line) - if i+1 < len(rows) && rows[i+1].wrapped { + if i+1 < len(rows) && rows[i+1].boundary == boundarySoft { take = len(row.line) } if i == cursorRow { @@ -113,6 +127,11 @@ func groupSemanticRows(rows []semanticRow, cursorRow, cursorX int) []logicalRows group.hasCursor = true group.cursorOffset = len(group.cells) + min(cursorX, take) } + if i == savedRow { + take = max(take, min(savedX, len(row.line))) + group.hasSaved = true + group.savedOffset = len(group.cells) + min(savedX, take) + } group.cells = append(group.cells, row.line[:take]...) } return groups @@ -128,14 +147,10 @@ func semanticLineLength(line uv.Line) int { return 0 } -func packLogicalRows(group logicalRows, width int) ([]ScrollbackRow, int, int, bool) { - rows := []ScrollbackRow{{ - Line: uv.NewLine(width), - HeadTruncated: group.headTruncated, - }} +func packLogicalRows(group logicalRows, width int) packedLogicalRows { + rows := []semanticRow{{line: uv.NewLine(width), boundary: group.headBoundary}} y, x := 0, 0 - cursorY, cursorX := 0, 0 - cursorMapped := false + var cursor, saved mappedPosition for i := 0; i < len(group.cells); { cell := group.cells[i] @@ -150,45 +165,50 @@ func packLogicalRows(group logicalRows, width int) ([]ScrollbackRow, int, int, b if x > 0 && x+cellWidth > width { y++ x = 0 - rows = append(rows, ScrollbackRow{Line: uv.NewLine(width), Wrapped: true}) + rows = append(rows, semanticRow{line: uv.NewLine(width), boundary: boundarySoft}) } - if group.hasCursor && !cursorMapped && group.cursorOffset >= i && group.cursorOffset < i+cellWidth { - cursorY = y - cursorX = min(x+group.cursorOffset-i, width-1) - cursorMapped = true + if group.hasCursor && !cursor.found && group.cursorOffset >= i && group.cursorOffset < i+cellWidth { + cursor = mappedPosition{x: min(x+group.cursorOffset-i, width-1), y: y, found: true} } - rows[y].Line.Set(x, &cell) + if group.hasSaved && !saved.found && group.savedOffset >= i && group.savedOffset < i+cellWidth { + saved = mappedPosition{x: min(x+group.savedOffset-i, width-1), y: y, found: true} + } + rows[y].line.Set(x, &cell) x += cellWidth i += cellWidth if x >= width && i < len(group.cells) { y++ x = 0 - rows = append(rows, ScrollbackRow{Line: uv.NewLine(width), Wrapped: true}) + rows = append(rows, semanticRow{line: uv.NewLine(width), boundary: boundarySoft}) } } - if group.hasCursor && !cursorMapped { - cursorY = y - cursorX = min(x, width-1) - cursorMapped = true + if group.hasCursor && !cursor.found { + cursor = mappedPosition{x: min(x, width-1), y: y, found: true} + } + if group.hasSaved && !saved.found { + saved = mappedPosition{x: min(x, width-1), y: y, found: true} } - return rows, cursorY, cursorX, cursorMapped + return packedLogicalRows{rows: rows, cursor: cursor, saved: saved} } -func (s *Screen) snapshotRows(includeScrollback bool) []ScrollbackRow { - scrollbackLen := 0 - if s.scrollback != nil { - scrollbackLen = s.scrollback.Len() +func (s *Screen) snapshotRows(includeScrollback bool) ([]semanticRow, error) { + if err := s.buf.validate(); err != nil { + return nil, fmt.Errorf("visible semantic buffer: %w", err) } - rows := make([]ScrollbackRow, 0, s.buf.Height()+scrollbackLen) + var rows []semanticRow if includeScrollback && s.scrollback != nil { - rows = append(rows, s.scrollback.Rows()...) + rows = s.scrollback.semanticRows() + } + rows = append(rows, s.buf.allRows()...) + for i, row := range rows { + if !row.boundary.valid() { + return nil, fmt.Errorf("snapshot row %d has invalid boundary %d", i, row.boundary) + } + rows[i].line = slices.Clone(row.line) } - for y := 0; y < s.buf.Height(); y++ { - rows = append(rows, ScrollbackRow{ - Line: s.buf.Line(y), - Wrapped: s.rowWrapped(y), - }) + if len(rows) > 0 && rows[0].boundary == boundarySoft { + return nil, fmt.Errorf("snapshot begins with soft boundary") } - return rows + return rows, nil } diff --git a/vt/safe_emulator.go b/vt/safe_emulator.go index 73d743b3..993accf0 100644 --- a/vt/safe_emulator.go +++ b/vt/safe_emulator.go @@ -48,6 +48,13 @@ func (se *SafeEmulator) Render() string { return se.Emulator.Render() } +// ReattachSnapshot observes and serializes one lock-protected emulator state. +func (se *SafeEmulator) ReattachSnapshot() ([]byte, error) { + se.mu.RLock() + defer se.mu.RUnlock() + return se.Emulator.ReattachSnapshot() +} + // SetCell sets a cell in the emulator in a concurrency-safe manner. func (se *SafeEmulator) SetCell(x, y int, cell *uv.Cell) { se.mu.Lock() diff --git a/vt/screen.go b/vt/screen.go index 4574befb..248bc607 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -9,11 +9,8 @@ import ( type Screen struct { // cb is the callbacks struct to use. cb *Callbacks - // The buffer of the screen. - buf *uv.RenderBuffer - // wrapped records whether each physical row continues a terminal - // autowrap from the previous row. It is kept parallel to buf rows. - wrapped []bool + // buf owns both visible cells and their semantic row boundaries. + buf *semanticBuffer // The cur of the screen. cur, saved Cursor // scroll is the scroll region. @@ -25,11 +22,10 @@ type Screen struct { // NewScreen creates a new screen. func NewScreen(w, h int) *Screen { s := Screen{ - buf: uv.NewRenderBuffer(w, h), - wrapped: make([]bool, h), + buf: newSemanticBuffer(w, h), scrollback: NewScrollback(DefaultScrollbackSize), } - s.scroll = s.buf.Bounds() + s.scroll = s.buf.bounds() return &s } @@ -37,64 +33,60 @@ func NewScreen(w, h int) *Screen { // It clears the screen, sets the cursor to the top left corner, reset the // cursor styles, and resets the scroll region. func (s *Screen) Reset() { - s.buf.Clear() - clear(s.wrapped) + s.buf.reset() s.cur = Cursor{} s.saved = Cursor{} - s.scroll = s.buf.Bounds() - s.buf.Touched = nil + s.scroll = s.buf.bounds() } // Bounds returns the bounds of the screen. func (s *Screen) Bounds() uv.Rectangle { - return s.buf.Bounds() + return s.buf.bounds() } // Touched returns touched lines in the screen buffer. func (s *Screen) Touched() []*uv.LineData { - return s.buf.Touched + return s.buf.touched() } // ClearTouched clears the touched state. func (s *Screen) ClearTouched() { - s.buf.Touched = nil + s.buf.clearTouched() } // CellAt returns the cell at the given x, y position. func (s *Screen) CellAt(x int, y int) *uv.Cell { - return s.buf.CellAt(x, y) + return s.buf.cellAt(x, y) } // SetCell sets the cell at the given x, y position. func (s *Screen) SetCell(x, y int, c *uv.Cell) { - s.buf.SetCell(x, y, c) + s.buf.setCell(x, y, c) } // Height returns the height of the screen. func (s *Screen) Height() int { - return s.buf.Height() + return s.buf.height() } // Resize resizes the screen. func (s *Screen) Resize(width int, height int) { if s.buf == nil { - s.buf = uv.NewRenderBuffer(width, height) - s.wrapped = make([]bool, height) + s.buf = newSemanticBuffer(width, height) } else { s.resizeReflow(width, height) } - s.scroll = s.buf.Bounds() + s.scroll = s.buf.bounds() } // Width returns the width of the screen. func (s *Screen) Width() int { - return s.buf.Width() + return s.buf.widthValue() } // Clear clears the screen with blank cells. func (s *Screen) Clear() { s.ClearArea(s.Bounds()) - clear(s.wrapped) } // ClearWithScrollback saves all non-empty lines to scrollback before clearing. @@ -103,10 +95,19 @@ func (s *Screen) Clear() { func (s *Screen) ClearWithScrollback() { if s.scrollback != nil { // Save all lines that have content before clearing - for y := 0; y < s.buf.Height(); y++ { - line := s.buf.Line(y) + lastSaved := -1 + for y := 0; y < s.buf.height(); y++ { + line := s.buf.visibleLine(y) if line != nil && !s.isLineEmpty(line) { - s.scrollback.PushWrapped(line, s.rowWrapped(y)) + row := semanticRow{ + line: line, + boundary: s.buf.boundary(y), + } + if y != lastSaved+1 && row.boundary == boundarySoft { + row.boundary = boundaryHard + } + s.scrollback.pushSemanticRow(row) + lastSaved = y } } } @@ -125,7 +126,7 @@ func (s *Screen) isLineEmpty(line uv.Line) bool { // ClearArea clears the given area. func (s *Screen) ClearArea(area uv.Rectangle) { - s.buf.ClearArea(area) + s.buf.clearArea(area) s.touchArea(area) } @@ -136,7 +137,7 @@ func (s *Screen) Fill(c *uv.Cell) { // FillArea fills the given area with the given cell. func (s *Screen) FillArea(c *uv.Cell, area uv.Rectangle) { - s.buf.FillArea(c, area) + s.buf.fillArea(c, area) s.touchArea(area) } @@ -163,8 +164,8 @@ func (s *Screen) setCursorX(x int, margins bool) { func (s *Screen) setCursor(x, y int, margins bool) { old := s.cur.Position if !margins { - y = ordered.Clamp(y, 0, s.buf.Height()-1) - x = ordered.Clamp(x, 0, s.buf.Width()-1) + y = ordered.Clamp(y, 0, s.buf.height()-1) + x = ordered.Clamp(x, 0, s.buf.widthValue()-1) } else { y = ordered.Clamp(s.scroll.Min.Y+y, s.scroll.Min.Y, s.scroll.Max.Y-1) x = ordered.Clamp(s.scroll.Min.X+x, s.scroll.Min.X, s.scroll.Max.X-1) @@ -188,7 +189,7 @@ func (s *Screen) moveCursor(dx, dy int) { scroll.Min.X = 0 } if old.X >= scroll.Max.X { - scroll.Max.X = s.buf.Width() + scroll.Max.X = s.buf.widthValue() } pt := uv.Pos(s.cur.X+dx, s.cur.Y+dy) @@ -198,8 +199,8 @@ func (s *Screen) moveCursor(dx, dy int) { y = ordered.Clamp(pt.Y, scroll.Min.Y, scroll.Max.Y-1) x = ordered.Clamp(pt.X, scroll.Min.X, scroll.Max.X-1) } else { - y = ordered.Clamp(pt.Y, 0, s.buf.Height()-1) - x = ordered.Clamp(pt.X, 0, s.buf.Width()-1) + y = ordered.Clamp(pt.Y, 0, s.buf.height()-1) + x = ordered.Clamp(pt.X, 0, s.buf.widthValue()-1) } s.cur.X, s.cur.Y = x, y @@ -286,7 +287,7 @@ func (s *Screen) InsertCell(n int) { } x, y := s.cur.X, s.cur.Y - s.buf.InsertCellArea(x, y, n, s.blankCell(), s.scroll) + s.buf.insertCells(x, y, n, s.blankCell(), s.scroll) } // DeleteCell deletes n cells at the cursor position moving cells to the left. @@ -297,7 +298,7 @@ func (s *Screen) DeleteCell(n int) { } x, y := s.cur.X, s.cur.Y - s.buf.DeleteCellArea(x, y, n, s.blankCell(), s.scroll) + s.buf.deleteCells(x, y, n, s.blankCell(), s.scroll) } // ScrollUp scrolls the content up n lines within the given region. Lines @@ -337,8 +338,7 @@ func (s *Screen) InsertLine(n int) bool { return false } - s.buf.InsertLineArea(y, n, s.blankCell(), s.scroll) - s.insertWrappedRows(y, n, s.scroll) + s.buf.insertRows(y, n, s.blankCell(), s.scroll) return true } @@ -366,15 +366,19 @@ func (s *Screen) DeleteLine(n int) bool { // Save lines to scrollback if we're at the top of the scroll region // and the scroll region uses the full width (typical terminal scroll). // This captures lines that would be lost during scroll up operations. - if s.scrollback != nil && y == scroll.Min.Y && - scroll.Min.X == 0 && scroll.Max.X == s.buf.Width() { + savedToScrollback := s.scrollback != nil && y == scroll.Min.Y && + scroll.Min.X == 0 && scroll.Max.X == s.buf.widthValue() + if savedToScrollback { // Save lines that will be deleted linesToSave := min(n, scroll.Max.Y-y) - s.scrollback.PushN(s.buf, s.wrapped, y, linesToSave) + s.scrollback.pushSemanticRows(s.buf.rows(y, linesToSave)) } - s.buf.DeleteLineArea(y, n, s.blankCell(), scroll) - s.deleteWrappedRows(y, n, scroll) + s.buf.deleteRows(y, n, s.blankCell(), scroll) + if !savedToScrollback && scroll.Min.X == 0 && scroll.Max.X == s.buf.widthValue() && + s.buf.boundary(y) == boundarySoft { + s.buf.setBoundary(y, boundaryTruncatedHead) + } return true } @@ -395,7 +399,7 @@ func (s *Screen) blankCell() *uv.Cell { // touchArea marks all lines in the given area as touched. func (s *Screen) touchArea(area uv.Rectangle) { for y := area.Min.Y; y < area.Max.Y; y++ { - s.buf.TouchLine(area.Min.X, y, area.Max.X-area.Min.X) + s.buf.touchLine(area.Min.X, y, area.Max.X-area.Min.X) } } @@ -420,42 +424,12 @@ func (s *Screen) SetScrollbackSize(maxLines int) { } // setCurrentRowWrapped records whether the cursor row begins as an autowrap -// continuation. Explicit line transitions call this with false. +// continuation. Explicit line transitions harden the destination row. func (s *Screen) setCurrentRowWrapped(wrapped bool) { _, y := s.CursorPosition() - if y >= 0 && y < len(s.wrapped) { - s.wrapped[y] = wrapped - } -} - -func (s *Screen) rowWrapped(y int) bool { - return y >= 0 && y < len(s.wrapped) && s.wrapped[y] -} - -func (s *Screen) insertWrappedRows(y, n int, area uv.Rectangle) { - maxY := min(area.Max.Y, len(s.wrapped)) - n = min(n, maxY-y) - if n <= 0 || y < 0 || y >= maxY { - return - } - if area.Min.X != 0 || area.Max.X != s.buf.Width() { - clear(s.wrapped[y:maxY]) - return - } - copy(s.wrapped[y+n:maxY], s.wrapped[y:maxY-n]) - clear(s.wrapped[y : y+n]) -} - -func (s *Screen) deleteWrappedRows(y, n int, area uv.Rectangle) { - maxY := min(area.Max.Y, len(s.wrapped)) - n = min(n, maxY-y) - if n <= 0 || y < 0 || y >= maxY { - return - } - if area.Min.X != 0 || area.Max.X != s.buf.Width() { - clear(s.wrapped[y:maxY]) - return + boundary := boundaryHard + if wrapped { + boundary = boundarySoft } - copy(s.wrapped[y:maxY-n], s.wrapped[y+n:maxY]) - clear(s.wrapped[maxY-n : maxY]) + s.buf.setBoundary(y, boundary) } diff --git a/vt/scrollback.go b/vt/scrollback.go index ea62c1c4..8b678f7c 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -9,88 +9,83 @@ import ( // DefaultScrollbackSize is the default maximum number of lines in the scrollback buffer. const DefaultScrollbackSize = 10000 -// Scrollback represents a scrollback buffer that stores lines scrolled off the screen. +// Scrollback stores retained semantic rows. Its public read API returns value +// projections so callers cannot mutate retained terminal state through aliases. type Scrollback struct { - rows []ScrollbackRow + rows []semanticRow maxLines int } -// ScrollbackRow is a physical terminal row plus the semantic boundary that -// precedes it. Wrapped is true only when the row continues a terminal -// autowrap from the previous retained row. HeadTruncated marks a retained -// fragment whose preceding soft-wrapped rows were evicted by the cap. -type ScrollbackRow struct { - Line uv.Line - Wrapped bool - HeadTruncated bool -} - // NewScrollback creates a new scrollback buffer with the given maximum number of lines. func NewScrollback(maxLines int) *Scrollback { if maxLines <= 0 { maxLines = DefaultScrollbackSize } return &Scrollback{ - rows: make([]ScrollbackRow, 0, min(maxLines, 1000)), // Pre-allocate reasonable capacity + rows: make([]semanticRow, 0, min(maxLines, 1000)), maxLines: maxLines, } } -// Push adds a line to the scrollback buffer. -// If the buffer is full, the oldest line is removed. +// Push adds a hard-boundary line to the scrollback buffer. func (s *Scrollback) Push(line uv.Line) { - s.PushWrapped(line, false) + s.pushSemanticRow(semanticRow{line: line, boundary: boundaryHard}) } -// PushWrapped adds a row while preserving whether it is a soft-wrap -// continuation of the previous physical row. -func (s *Scrollback) PushWrapped(line uv.Line, wrapped bool) { - if s == nil || s.maxLines <= 0 { +// PushN adds n hard-boundary lines from the buffer starting at line y. +// Semantic screen transfers use pushSemanticRows so boundary provenance stays +// inside the owning package rather than widening this established API. +func (s *Scrollback) PushN(buf *uv.RenderBuffer, y, n int) { + if s == nil || buf == nil || n <= 0 || y < 0 || y >= buf.Height() { return } - - // Find last non-empty cell to trim trailing empty cells. - // This helps with wrapping and window resizing. - lastNonEmpty := -1 - for i := len(line) - 1; i >= 0; i-- { - c := &line[i] - if !c.IsZero() && !c.Equal(&uv.EmptyCell) { - lastNonEmpty = i - break + for i := range min(n, buf.Height()-y) { + if line := buf.Line(y + i); line != nil { + s.Push(line) } } +} - // Clone the line content up to and including the last non-empty cell - cloned := slices.Clone(line[:lastNonEmpty+1]) - - evicted := len(s.rows) >= s.maxLines - if evicted { - // Remove oldest line and append new one - s.rows = slices.Delete(s.rows, 0, 1) - if len(s.rows) > 0 && s.rows[0].Wrapped { - s.rows[0].Wrapped = false - s.rows[0].HeadTruncated = true - } - } - s.rows = append(s.rows, ScrollbackRow{Line: cloned, Wrapped: wrapped}) - if evicted && len(s.rows) > 0 && s.rows[0].Wrapped { - s.rows[0].Wrapped = false - s.rows[0].HeadTruncated = true +func (s *Scrollback) pushSemanticRows(rows []semanticRow) { + for _, row := range rows { + s.pushSemanticRow(row) } } -// PushN adds n lines from the buffer starting at line y to the scrollback. -func (s *Scrollback) PushN(buf *uv.RenderBuffer, wrapped []bool, y, n int) { - if s == nil || buf == nil || n <= 0 { +func (s *Scrollback) pushSemanticRow(row semanticRow) { + if s == nil || s.maxLines <= 0 { return } + row.line = trimAndCloneLine(row.line) + if !row.boundary.valid() { + row.boundary = boundaryHard + } + if len(s.rows) == 0 && row.boundary == boundarySoft { + row.boundary = boundaryTruncatedHead + } + s.rows = append(s.rows, row) + if len(s.rows) > s.maxLines { + s.rows = slices.Delete(s.rows, 0, len(s.rows)-s.maxLines) + s.normalizeHead() + } +} - for i := range min(n, buf.Height()-y) { - if line := buf.Line(y + i); line != nil { - isWrapped := y+i < len(wrapped) && wrapped[y+i] - s.PushWrapped(line, isWrapped) +func trimAndCloneLine(line uv.Line) uv.Line { + lastNonEmpty := -1 + for i := len(line) - 1; i >= 0; i-- { + cell := &line[i] + if !cell.IsZero() && !cell.Equal(&uv.EmptyCell) { + lastNonEmpty = i + break } } + return slices.Clone(line[:lastNonEmpty+1]) +} + +func (s *Scrollback) normalizeHead() { + if len(s.rows) > 0 && s.rows[0].boundary == boundarySoft { + s.rows[0].boundary = boundaryTruncatedHead + } } // Len returns the number of lines in the scrollback buffer. @@ -110,84 +105,73 @@ func (s *Scrollback) MaxLines() int { } // SetMaxLines sets the maximum number of lines in the scrollback buffer. -// If the current number of lines exceeds the new maximum, oldest lines are removed. func (s *Scrollback) SetMaxLines(maxLines int) { if s == nil || maxLines <= 0 { return } - s.maxLines = maxLines if len(s.rows) > maxLines { - // Remove oldest lines - s.rows = s.rows[len(s.rows)-maxLines:] - if len(s.rows) > 0 && s.rows[0].Wrapped { - s.rows[0].Wrapped = false - s.rows[0].HeadTruncated = true - } + s.rows = slices.Clone(s.rows[len(s.rows)-maxLines:]) + s.normalizeHead() } } -// Line returns the line at the given index. -// Index 0 is the oldest line, Len()-1 is the most recent. -// Returns nil if index is out of bounds. +// Line returns a defensive copy of the line at the given index. func (s *Scrollback) Line(index int) uv.Line { if s == nil || index < 0 || index >= len(s.rows) { return nil } - return s.rows[index].Line + return slices.Clone(s.rows[index].line) } -// Lines returns all lines in the scrollback buffer. -// Index 0 is the oldest line. +// Lines returns defensive copies of all retained lines, oldest first. func (s *Scrollback) Lines() []uv.Line { if s == nil { return nil } lines := make([]uv.Line, len(s.rows)) for i := range s.rows { - lines[i] = s.rows[i].Line + lines[i] = slices.Clone(s.rows[i].line) } return lines } -// Rows returns the retained rows and their boundary provenance. -func (s *Scrollback) Rows() []ScrollbackRow { +func (s *Scrollback) semanticRows() []semanticRow { if s == nil { return nil } - return s.rows + rows := make([]semanticRow, len(s.rows)) + for i, row := range s.rows { + rows[i] = cloneSemanticRow(row) + } + return rows } -// ReplaceRows atomically replaces retained history after a semantic reflow. -func (s *Scrollback) ReplaceRows(rows []ScrollbackRow) { +func (s *Scrollback) replaceSemanticRows(rows []semanticRow) { if s == nil { return } if len(rows) > s.maxLines { rows = rows[len(rows)-s.maxLines:] - if len(rows) > 0 && rows[0].Wrapped { - rows[0].Wrapped = false - rows[0].HeadTruncated = true - } } - s.rows = slices.Clone(rows) + s.rows = make([]semanticRow, len(rows)) + for i, row := range rows { + s.rows[i] = cloneSemanticRow(row) + } + s.normalizeHead() } // Clear removes all lines from the scrollback buffer. func (s *Scrollback) Clear() { - if s == nil { - return + if s != nil { + s.rows = s.rows[:0] } - s.rows = s.rows[: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. +// CellAt returns a defensive copy of a cell in the scrollback buffer. func (s *Scrollback) CellAt(x, y int) *uv.Cell { - line := s.Line(y) - if line == nil || x < 0 || x >= len(line) { + if s == nil || y < 0 || y >= len(s.rows) || x < 0 || x >= len(s.rows[y].line) { return nil } - return &line[x] + return s.rows[y].line[x].Clone() } diff --git a/vt/semantic_buffer.go b/vt/semantic_buffer.go new file mode 100644 index 00000000..0e19f52d --- /dev/null +++ b/vt/semantic_buffer.go @@ -0,0 +1,311 @@ +package vt + +import ( + "fmt" + "slices" + + uv "github.com/charmbracelet/ultraviolet" +) + +// rowBoundary describes how a physical row begins. Keeping this as one value +// makes impossible states such as "soft and truncated" unrepresentable. +type rowBoundary uint8 + +const ( + boundaryHard rowBoundary = iota + boundarySoft + boundaryTruncatedHead +) + +func (b rowBoundary) valid() bool { + return b == boundaryHard || b == boundarySoft || b == boundaryTruncatedHead +} + +// semanticRow is the value projection used when rows cross the visible-grid +// and scrollback boundary. The contained line is always cloned at ownership +// boundaries. +type semanticRow struct { + line uv.Line + boundary rowBoundary +} + +func cloneSemanticRow(row semanticRow) semanticRow { + return semanticRow{line: slices.Clone(row.line), boundary: row.boundary} +} + +// semanticBuffer is the sole mutation owner for visible cells and their row +// boundaries. The backing RenderBuffer may be wider than width on the +// alternate screen so clipped cells survive a shrink-grow cycle. +type semanticBuffer struct { + cells *uv.RenderBuffer + width int + boundaries []rowBoundary +} + +func newSemanticBuffer(width, height int) *semanticBuffer { + return &semanticBuffer{ + cells: uv.NewRenderBuffer(width, height), + width: width, + boundaries: make([]rowBoundary, height), + } +} + +func (b *semanticBuffer) bounds() uv.Rectangle { + return uv.Rect(0, 0, b.width, b.height()) +} + +func (b *semanticBuffer) widthValue() int { return b.width } + +func (b *semanticBuffer) height() int { + if b == nil || b.cells == nil { + return 0 + } + return b.cells.Height() +} + +func (b *semanticBuffer) visibleLine(y int) uv.Line { + if b == nil || b.cells == nil { + return nil + } + line := b.cells.Line(y) + if len(line) > b.width { + line = line[:b.width] + } + return line +} + +func (b *semanticBuffer) cellAt(x, y int) *uv.Cell { + if x < 0 || x >= b.width { + return nil + } + cell := b.cells.CellAt(x, y) + if cell == nil { + return nil + } + return cell.Clone() +} + +func (b *semanticBuffer) setCell(x, y int, cell *uv.Cell) { + if x < 0 || x >= b.width || y < 0 || y >= b.height() { + return + } + b.cells.SetCell(x, y, cell) +} + +func (b *semanticBuffer) touched() []*uv.LineData { + if b == nil || b.cells == nil { + return nil + } + result := make([]*uv.LineData, len(b.cells.Touched)) + for i, line := range b.cells.Touched { + if line != nil { + copy := *line + result[i] = © + } + } + return result +} + +func (b *semanticBuffer) clearTouched() { b.cells.Touched = nil } + +func (b *semanticBuffer) touchLine(x, y, n int) { b.cells.TouchLine(x, y, n) } + +func (b *semanticBuffer) reset() { + b.cells.Clear() + clear(b.boundaries) + b.cells.Touched = nil +} + +func (b *semanticBuffer) clearArea(area uv.Rectangle) { + area = area.Intersect(b.bounds()) + if area.Empty() { + return + } + b.cells.ClearArea(area) + b.hardenFullyReplacedRows(area) +} + +func (b *semanticBuffer) fillArea(cell *uv.Cell, area uv.Rectangle) { + area = area.Intersect(b.bounds()) + if area.Empty() { + return + } + b.cells.FillArea(cell, area) + b.hardenFullyReplacedRows(area) +} + +func (b *semanticBuffer) hardenFullyReplacedRows(area uv.Rectangle) { + if area.Min.X != 0 || area.Max.X < b.width { + return + } + for y := area.Min.Y; y < area.Max.Y; y++ { + b.setBoundary(y, boundaryHard) + // A continuation cannot cross a row whose contents were replaced. + b.setBoundary(y+1, boundaryHard) + } +} + +func (b *semanticBuffer) insertCells(x, y, n int, cell *uv.Cell, area uv.Rectangle) { + b.cells.InsertCellArea(x, y, n, cell, area.Intersect(b.bounds())) +} + +func (b *semanticBuffer) deleteCells(x, y, n int, cell *uv.Cell, area uv.Rectangle) { + b.cells.DeleteCellArea(x, y, n, cell, area.Intersect(b.bounds())) +} + +func (b *semanticBuffer) insertRows(y, n int, cell *uv.Cell, area uv.Rectangle) { + area = area.Intersect(b.bounds()) + b.cells.InsertLineArea(y, n, cell, area) + b.shiftBoundariesDown(y, n, area) +} + +func (b *semanticBuffer) deleteRows(y, n int, cell *uv.Cell, area uv.Rectangle) { + area = area.Intersect(b.bounds()) + b.cells.DeleteLineArea(y, n, cell, area) + b.shiftBoundariesUp(y, n, area) +} + +func (b *semanticBuffer) rows(y, n int) []semanticRow { + if y < 0 || y >= b.height() || n <= 0 { + return nil + } + n = min(n, b.height()-y) + rows := make([]semanticRow, n) + for i := range n { + rows[i] = semanticRow{ + line: slices.Clone(b.visibleLine(y + i)), + boundary: b.boundary(y + i), + } + } + return rows +} + +func (b *semanticBuffer) allRows() []semanticRow { return b.rows(0, b.height()) } + +func (b *semanticBuffer) boundary(y int) rowBoundary { + if y < 0 || y >= len(b.boundaries) { + return boundaryHard + } + return b.boundaries[y] +} + +func (b *semanticBuffer) setBoundary(y int, boundary rowBoundary) { + if y < 0 || y >= len(b.boundaries) { + return + } + b.boundaries[y] = boundary +} + +func (b *semanticBuffer) shiftBoundariesDown(y, n int, area uv.Rectangle) { + maxY := min(area.Max.Y, len(b.boundaries)) + n = min(n, maxY-y) + if n <= 0 || y < 0 || y >= maxY { + return + } + if area.Min.X != 0 || area.Max.X != b.width { + for row := y; row < maxY; row++ { + b.boundaries[row] = boundaryHard + } + return + } + copy(b.boundaries[y+n:maxY], b.boundaries[y:maxY-n]) + clear(b.boundaries[y : y+n]) +} + +func (b *semanticBuffer) shiftBoundariesUp(y, n int, area uv.Rectangle) { + maxY := min(area.Max.Y, len(b.boundaries)) + n = min(n, maxY-y) + if n <= 0 || y < 0 || y >= maxY { + return + } + if area.Min.X != 0 || area.Max.X != b.width { + for row := y; row < maxY; row++ { + b.boundaries[row] = boundaryHard + } + return + } + copy(b.boundaries[y:maxY-n], b.boundaries[y+n:maxY]) + clear(b.boundaries[maxY-n : maxY]) +} + +func (b *semanticBuffer) replace(width, height int, rows []semanticRow) { + next := uv.NewRenderBuffer(width, height) + boundaries := make([]rowBoundary, height) + for y := 0; y < min(height, len(rows)); y++ { + copy(next.Line(y), rows[y].line) + boundaries[y] = rows[y].boundary + } + next.Touched = nil + b.cells = next + b.width = width + b.boundaries = boundaries +} + +// resizePhysical resizes an alternate-screen grid without semantic grouping. +// It returns the row shift applied to cursor-bearing coordinates. +func (b *semanticBuffer) resizePhysical(width, height, cursorY int) int { + if width <= 0 || height <= 0 { + return 0 + } + storageWidth := b.cells.Width() + if width > storageWidth { + b.cells.Resize(width, b.height()) + storageWidth = width + } + b.width = width + + oldHeight := b.height() + if height == oldHeight { + b.cells.Touched = nil + return 0 + } + + start, end, shift := 0, oldHeight, 0 + if height < oldHeight { + remove := oldHeight - height + belowCursor := max(0, oldHeight-1-cursorY) + removeBelow := min(remove, belowCursor) + removeAbove := remove - removeBelow + start = removeAbove + end = oldHeight - removeBelow + shift = -removeAbove + } + + next := uv.NewRenderBuffer(storageWidth, height) + boundaries := make([]rowBoundary, height) + for dst, src := 0, start; dst < height && src < end; dst, src = dst+1, src+1 { + copy(next.Line(dst), b.cells.Line(src)) + boundaries[dst] = b.boundary(src) + } + if start > 0 && len(boundaries) > 0 && boundaries[0] == boundarySoft { + boundaries[0] = boundaryTruncatedHead + } + next.Touched = nil + b.cells = next + b.boundaries = boundaries + return shift +} + +func (b *semanticBuffer) string() string { return uv.Lines(b.visibleLines()).String() } + +func (b *semanticBuffer) render() string { return uv.Lines(b.visibleLines()).Render() } + +func (b *semanticBuffer) visibleLines() uv.Lines { + lines := make(uv.Lines, b.height()) + for y := range lines { + lines[y] = b.visibleLine(y) + } + return lines +} + +func (b *semanticBuffer) validate() error { + if len(b.boundaries) != b.height() { + return fmt.Errorf("row boundary count %d does not match height %d", len(b.boundaries), b.height()) + } + for y, boundary := range b.boundaries { + if !boundary.valid() { + return fmt.Errorf("row %d has invalid boundary %d", y, boundary) + } + } + return nil +} diff --git a/vt/semantic_contract_test.go b/vt/semantic_contract_test.go new file mode 100644 index 00000000..a836d00e --- /dev/null +++ b/vt/semantic_contract_test.go @@ -0,0 +1,165 @@ +package vt + +import ( + "errors" + "strings" + "sync" + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +func TestEraseLineBreaksStaleSoftBoundary(t *testing.T) { + e := NewEmulator(5, 3) + e.WriteString("abcdef") + e.WriteString("\x1b[2KZ") + + got := snapshotString(t, e) + if !strings.Contains(got, "abcde\r\n Z") { + t.Fatalf("snapshot = %q, want hard boundary before rewritten row", got) + } +} + +func TestScrollbackReadsAreDefensive(t *testing.T) { + sb := NewScrollback(2) + line := uv.NewLine(2) + line.Set(0, &uv.Cell{Content: "a", Width: 1}) + sb.Push(line) + + fromLine := sb.Line(0) + fromLine[0].Content = "x" + fromLines := sb.Lines() + fromLines[0][0].Content = "y" + fromCell := sb.CellAt(0, 0) + fromCell.Content = "z" + + if got := sb.Line(0)[0].Content; got != "a" { + t.Fatalf("stored cell = %q, want defensive read preserving a", got) + } +} + +func TestScreenCellReadIsDefensive(t *testing.T) { + s := NewScreen(2, 1) + s.SetCell(0, 0, &uv.Cell{Content: "a", Width: 1}) + cell := s.CellAt(0, 0) + cell.Content = "x" + if got := s.CellAt(0, 0).Content; got != "a" { + t.Fatalf("stored screen cell = %q, want defensive read preserving a", got) + } +} + +func TestAlternateResizePreservesPhysicalRowsAndClippedCells(t *testing.T) { + e := NewEmulator(6, 2) + e.WriteString("\x1b[?1049habcdef\r\nXYZ") + + e.Resize(3, 2) + if got := e.Render(); !strings.Contains(got, "abc\nXYZ") { + t.Fatalf("alternate render after shrink = %q, want clipped physical rows", got) + } + e.Resize(6, 2) + + got := e.Render() + if !strings.Contains(got, "abcdef\nXYZ") { + t.Fatalf("alternate render after shrink-grow = %q, want physical rows and clipped cells preserved", got) + } + if e.scrs[0].scrollback.Len() != 0 { + t.Fatalf("primary scrollback changed during alternate resize: %d", e.scrs[0].scrollback.Len()) + } +} + +func TestAlternateHeightResizeIsCursorAnchored(t *testing.T) { + e := NewEmulator(4, 4) + e.WriteString("\x1b[?1049hA\r\nB\r\nC\r\nD\x1b[3;1H") + + e.Resize(4, 2) + + if got := e.Render(); !strings.Contains(got, "B\nC") { + t.Fatalf("alternate render after height shrink = %q, want rows around cursor", got) + } + if got := e.CursorPosition(); got.X != 0 || got.Y != 1 { + t.Fatalf("cursor after height shrink = %v, want (0,1)", got) + } + e.Resize(4, 4) + if got := e.Render(); !strings.HasPrefix(got, "B\nC\n\n") { + t.Fatalf("alternate render after height growth = %q, want retained rows plus blanks", got) + } +} + +func TestSnapshotFailureReturnsNoPartialBytes(t *testing.T) { + e := NewEmulator(5, 2) + e.WriteString("abc") + e.scr.buf.boundaries[0] = rowBoundary(255) + + got, err := e.ReattachSnapshot() + if got != nil { + t.Fatalf("failed snapshot bytes = %q, want nil", got) + } + var snapshotErr *SnapshotError + if !errors.As(err, &snapshotErr) { + t.Fatalf("snapshot error = %v, want *SnapshotError", err) + } +} + +func TestHorizontalEditCancelsPendingWrap(t *testing.T) { + for _, sequence := range []string{"\x1b[@", "\x1b[P"} { + t.Run(sequence, func(t *testing.T) { + e := NewEmulator(5, 2) + e.WriteString("abcde") + e.WriteString(sequence) + e.WriteString("Z") + + if got := e.CursorPosition(); got.Y != 0 { + t.Fatalf("cursor after edit and write = %v, want current row", got) + } + }) + } +} + +func TestPrimaryResizePreservesSavedCursorLogicalPosition(t *testing.T) { + e := NewEmulator(5, 3) + e.WriteString("abcdefgh") + e.WriteString("\x1b7") + + e.Resize(10, 3) + e.WriteString("\x1b8") + + if got := e.CursorPosition(); got.X != 8 || got.Y != 0 { + t.Fatalf("restored cursor after widening = %v, want logical position (8,0)", got) + } +} + +func TestResizePreservesCustomTabStops(t *testing.T) { + e := NewEmulator(12, 2) + e.WriteString("\x1b[3g\x1b[1;4H\x1bH") + + e.Resize(10, 2) + + if !e.tabstops.IsStop(3) { + t.Fatal("custom tab stop at column 3 was lost during resize") + } + if e.tabstops.IsStop(8) { + t.Fatal("cleared default tab stop at column 8 was recreated during resize") + } +} + +func TestSafeEmulatorSnapshotConcurrentObservation(t *testing.T) { + e := NewSafeEmulator(10, 3) + var wg sync.WaitGroup + for range 20 { + wg.Add(3) + go func() { + defer wg.Done() + _, _ = e.Write([]byte("abcdefghij")) + }() + go func() { + defer wg.Done() + e.Resize(5, 3) + e.Resize(10, 3) + }() + go func() { + defer wg.Done() + _, _ = e.ReattachSnapshot() + }() + } + wg.Wait() +}