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/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..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) @@ -215,32 +216,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) + e.scrs[1].resizePhysical(width, height) + e.tabstops.Resize(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/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 new file mode 100644 index 00000000..828bf802 --- /dev/null +++ b/vt/reattach.go @@ -0,0 +1,40 @@ +package vt + +import ( + "bytes" + "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, error) { + includeScrollback := !e.IsAltScreen() + 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].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(), nil +} diff --git a/vt/reattach_snapshot_test.go b/vt/reattach_snapshot_test.go new file mode 100644 index 00000000..b48efd8b --- /dev/null +++ b/vt/reattach_snapshot_test.go @@ -0,0 +1,119 @@ +package vt + +import ( + "strings" + "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 := snapshotString(t, e) + 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 := snapshotString(t, e) + 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 := snapshotString(t, e) + 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 := 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 = snapshotString(t, e) + 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.semanticRows() + if len(rows) != 1 { + t.Fatalf("scrollback rows = %d, want 1", len(rows)) + } + if rows[0].boundary != boundaryTruncatedHead { + t.Fatalf("retained head boundary = %v, want truncated head", rows[0].boundary) + } + got := snapshotString(t, e) + 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 := 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 { + t.Fatalf("cursor = %v, want (1,1)", got) + } +} diff --git a/vt/reflow.go b/vt/reflow.go new file mode 100644 index 00000000..d6376812 --- /dev/null +++ b/vt/reflow.go @@ -0,0 +1,214 @@ +package vt + +import ( + "fmt" + "slices" + + uv "github.com/charmbracelet/ultraviolet" +) + +type logicalRows struct { + cells uv.Line + headBoundary rowBoundary + hasCursor bool + cursorOffset int + hasSaved bool + savedOffset int +} + +type mappedPosition struct { + x, y int + found bool +} + +type packedLogicalRows struct { + rows []semanticRow + cursor, saved mappedPosition +} + +func (s *Screen) resizeReflow(width, height int) { + if width <= 0 || height <= 0 { + return + } + + rows, screenStartBefore := s.semanticRows() + groups := groupSemanticRows( + rows, + screenStartBefore+s.cur.Y, + s.cur.X, + screenStartBefore+s.saved.Y, + s.saved.X, + ) + + var ( + reflowed []semanticRow + cursorGlobalY int + cursorX int + cursorWasFound bool + savedGlobalY int + savedX int + savedWasFound bool + ) + for _, group := range groups { + packed := packLogicalRows(group, width) + if packed.cursor.found { + cursorGlobalY = len(reflowed) + packed.cursor.y + cursorX = packed.cursor.x + cursorWasFound = true + } + 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, semanticRow{line: uv.NewLine(width), boundary: boundaryHard}) + } + screenStart := max(0, len(reflowed)-height) + if s.scrollback != nil { + s.scrollback.replaceSemanticRows(reflowed[:screenStart]) + } + s.buf.replace(width, height, reflowed[screenStart:]) + + 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) + } + 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+shift, 0), height-1) + s.scroll = s.buf.bounds() +} + +func (s *Screen) semanticRows() ([]semanticRow, int) { + var rows []semanticRow + if s.scrollback != nil { + rows = s.scrollback.semanticRows() + } + screenStart := len(rows) + rows = append(rows, s.buf.allRows()...) + return rows, screenStart +} + +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.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].boundary == boundarySoft { + 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) + } + 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 +} + +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) packedLogicalRows { + rows := []semanticRow{{line: uv.NewLine(width), boundary: group.headBoundary}} + y, x := 0, 0 + var cursor, saved mappedPosition + + 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, semanticRow{line: uv.NewLine(width), boundary: boundarySoft}) + } + 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} + } + 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, semanticRow{line: uv.NewLine(width), boundary: boundarySoft}) + } + } + + 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 packedLogicalRows{rows: rows, cursor: cursor, saved: saved} +} + +func (s *Screen) snapshotRows(includeScrollback bool) ([]semanticRow, error) { + if err := s.buf.validate(); err != nil { + return nil, fmt.Errorf("visible semantic buffer: %w", err) + } + var rows []semanticRow + if includeScrollback && s.scrollback != nil { + 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) + } + if len(rows) > 0 && rows[0].boundary == boundarySoft { + return nil, fmt.Errorf("snapshot begins with soft boundary") + } + 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 04784f81..248bc607 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -9,8 +9,8 @@ import ( type Screen struct { // cb is the callbacks struct to use. cb *Callbacks - // The buffer of the screen. - buf *uv.RenderBuffer + // 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. @@ -22,10 +22,10 @@ type Screen struct { // NewScreen creates a new screen. func NewScreen(w, h int) *Screen { s := Screen{ - buf: uv.NewRenderBuffer(w, h), + buf: newSemanticBuffer(w, h), scrollback: NewScrollback(DefaultScrollbackSize), } - s.scroll = s.buf.Bounds() + s.scroll = s.buf.bounds() return &s } @@ -33,57 +33,55 @@ 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() + 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.buf = newSemanticBuffer(width, height) } else { - s.buf.Resize(width, height) - s.buf.Touched = nil + 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. @@ -97,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.Push(line) + 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 } } } @@ -119,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) } @@ -130,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) } @@ -157,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) @@ -182,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) @@ -192,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 @@ -280,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. @@ -291,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 @@ -331,7 +338,7 @@ func (s *Screen) InsertLine(n int) bool { return false } - s.buf.InsertLineArea(y, n, s.blankCell(), s.scroll) + s.buf.insertRows(y, n, s.blankCell(), s.scroll) return true } @@ -359,14 +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, y, linesToSave) + s.scrollback.pushSemanticRows(s.buf.rows(y, linesToSave)) } - s.buf.DeleteLineArea(y, n, s.blankCell(), 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 } @@ -387,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) } } @@ -410,3 +422,14 @@ func (s *Screen) SetScrollbackSize(maxLines int) { s.scrollback.SetMaxLines(maxLines) } } + +// setCurrentRowWrapped records whether the cursor row begins as an autowrap +// continuation. Explicit line transitions harden the destination row. +func (s *Screen) setCurrentRowWrapped(wrapped bool) { + _, y := s.CursorPosition() + boundary := boundaryHard + if wrapped { + boundary = boundarySoft + } + s.buf.setBoundary(y, boundary) +} diff --git a/vt/scrollback.go b/vt/scrollback.go index 59d03a03..8b678f7c 100644 --- a/vt/scrollback.go +++ b/vt/scrollback.go @@ -9,9 +9,10 @@ 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 { - lines []uv.Line + rows []semanticRow maxLines int } @@ -21,50 +22,70 @@ func NewScrollback(maxLines int) *Scrollback { maxLines = DefaultScrollbackSize } return &Scrollback{ - lines: make([]uv.Line, 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) { - if s == nil || s.maxLines <= 0 { + s.pushSemanticRow(semanticRow{line: line, boundary: boundaryHard}) +} + +// 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]) - - if len(s.lines) >= s.maxLines { - // Remove oldest line and append new one - s.lines = slices.Delete(s.lines, 0, 1) +func (s *Scrollback) pushSemanticRows(rows []semanticRow) { + for _, row := range rows { + s.pushSemanticRow(row) } - 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 { +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 { - s.Push(line) +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. @@ -72,7 +93,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. @@ -84,53 +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.lines) > maxLines { - // Remove oldest lines - s.lines = s.lines[len(s.lines)-maxLines:] + if len(s.rows) > maxLines { + 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.lines) { + if s == nil || index < 0 || index >= len(s.rows) { return nil } - return s.lines[index] + 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 } - return s.lines + lines := make([]uv.Line, len(s.rows)) + for i := range s.rows { + lines[i] = slices.Clone(s.rows[i].line) + } + return lines } -// Clear removes all lines from the scrollback buffer. -func (s *Scrollback) Clear() { +func (s *Scrollback) semanticRows() []semanticRow { + if s == nil { + return nil + } + rows := make([]semanticRow, len(s.rows)) + for i, row := range s.rows { + rows[i] = cloneSemanticRow(row) + } + return rows +} + +func (s *Scrollback) replaceSemanticRows(rows []semanticRow) { if s == nil { return } - s.lines = s.lines[:0] + if len(rows) > s.maxLines { + rows = rows[len(rows)-s.maxLines:] + } + 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 { + 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() +} 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 }