Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions vt/api_compat_test.go
Original file line number Diff line number Diff line change
@@ -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)
)
5 changes: 5 additions & 0 deletions vt/cc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand Down
34 changes: 12 additions & 22 deletions vt/emulator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions vt/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down Expand Up @@ -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
})

Expand All @@ -612,13 +614,15 @@ func (e *Emulator) registerDefaultCsiHandlers() {
// Move the cursor to the left margin.
e.scr.setCursorX(0, true)
}
e.atPhantom = false
return true
})

e.RegisterCsiHandler('P', func(params ansi.Params) bool {
// Delete Character [ansi.DCH]
n, _, _ := params.Param(0, 1)
e.scr.DeleteCell(n)
e.atPhantom = false
return true
})

Expand Down
40 changes: 40 additions & 0 deletions vt/reattach.go
Original file line number Diff line number Diff line change
@@ -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
}
119 changes: 119 additions & 0 deletions vt/reattach_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading