feat(pkg): port vt + renderer from ymux
Port the stdlib-only VT100/ANSI emulator (pkg/vt) and shadow-frame damage renderer (pkg/renderer) from the abandoned ymux project. Implementation and tests are copied as close to verbatim as possible; only the import path for the intra-repo dependency (github.com/bnema/ymux/pkg/terminal/renderer -> github.com/bnema/vev/pkg/renderer) was changed. No logic changes.
- commit
- 84a83dc9128ef2f50743d54c0c07e05e153473b5
- author
- brice · · 2026-07-02 09:09 UTC
- parent
- 16d10fa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +3898 -0
A pkg/renderer/benchmark_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func markBenchmarkFrame(f *Frame) { | |
| 6 | + | for y := range f.Height { | |
| 7 | + | for x := range f.Width { | |
| 8 | + | f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()}) | |
| 9 | + | } | |
| 10 | + | } | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | func BenchmarkRendererFullFrameDraw(b *testing.B) { | |
| 14 | + | frame := NewFrame(120, 40) | |
| 15 | + | markBenchmarkFrame(&frame) | |
| 16 | + | r := New(Capabilities{}) | |
| 17 | + | damage := []Damage{FullRedraw()} | |
| 18 | + | b.ReportAllocs() | |
| 19 | + | for b.Loop() { | |
| 20 | + | r.Reset() | |
| 21 | + | out, err := r.Draw(frame, damage) | |
| 22 | + | if err != nil { | |
| 23 | + | b.Fatal(err) | |
| 24 | + | } | |
| 25 | + | if len(out) == 0 { | |
| 26 | + | b.Fatal("expected renderer output") | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func BenchmarkRendererScrollFastPath(b *testing.B) { | |
| 32 | + | frame := NewFrame(120, 40) | |
| 33 | + | markBenchmarkFrame(&frame) | |
| 34 | + | r := New(Capabilities{}) | |
| 35 | + | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |
| 36 | + | b.Fatal(err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | scrolled := NewFrame(120, 40) | |
| 40 | + | copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120]) | |
| 41 | + | for i := 39 * 120; i < 40*120; i++ { | |
| 42 | + | scrolled.Cells[i] = BlankCell() | |
| 43 | + | } | |
| 44 | + | for x, r := range []rune("new output line") { | |
| 45 | + | scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()}) | |
| 46 | + | } | |
| 47 | + | damage := []Damage{ | |
| 48 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 120, Height: 40, Count: 1}, | |
| 49 | + | {Kind: DamageText, X: 0, Y: 39, Width: 120, Height: 1, Count: 1}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | b.ReportAllocs() | |
| 53 | + | for b.Loop() { | |
| 54 | + | r.replaceShadow(frame) | |
| 55 | + | out, err := r.Draw(scrolled, damage) | |
| 56 | + | if err != nil { | |
| 57 | + | b.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if len(out) == 0 { | |
| 60 | + | b.Fatal("expected renderer output") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func markBenchmarkFrame(f *Frame) { | |||
| 6 | + | for y := range f.Height { | |||
| 7 | + | for x := range f.Width { | |||
| 8 | + | f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()}) | |||
| 9 | + | } | |||
| 10 | + | } | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func BenchmarkRendererFullFrameDraw(b *testing.B) { | |||
| 14 | + | frame := NewFrame(120, 40) | |||
| 15 | + | markBenchmarkFrame(&frame) | |||
| 16 | + | r := New(Capabilities{}) | |||
| 17 | + | damage := []Damage{FullRedraw()} | |||
| 18 | + | b.ReportAllocs() | |||
| 19 | + | for b.Loop() { | |||
| 20 | + | r.Reset() | |||
| 21 | + | out, err := r.Draw(frame, damage) | |||
| 22 | + | if err != nil { | |||
| 23 | + | b.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | if len(out) == 0 { | |||
| 26 | + | b.Fatal("expected renderer output") | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func BenchmarkRendererScrollFastPath(b *testing.B) { | |||
| 32 | + | frame := NewFrame(120, 40) | |||
| 33 | + | markBenchmarkFrame(&frame) | |||
| 34 | + | r := New(Capabilities{}) | |||
| 35 | + | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 36 | + | b.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | scrolled := NewFrame(120, 40) | |||
| 40 | + | copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120]) | |||
| 41 | + | for i := 39 * 120; i < 40*120; i++ { | |||
| 42 | + | scrolled.Cells[i] = BlankCell() | |||
| 43 | + | } | |||
| 44 | + | for x, r := range []rune("new output line") { | |||
| 45 | + | scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()}) | |||
| 46 | + | } | |||
| 47 | + | damage := []Damage{ | |||
| 48 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 120, Height: 40, Count: 1}, | |||
| 49 | + | {Kind: DamageText, X: 0, Y: 39, Width: 120, Height: 1, Count: 1}, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | b.ReportAllocs() | |||
| 53 | + | for b.Loop() { | |||
| 54 | + | r.replaceShadow(frame) | |||
| 55 | + | out, err := r.Draw(scrolled, damage) | |||
| 56 | + | if err != nil { | |||
| 57 | + | b.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if len(out) == 0 { | |||
| 60 | + | b.Fatal("expected renderer output") | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
A pkg/renderer/cell.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | type Cell struct { | |
| 4 | + | Rune rune | |
| 5 | + | Style Style | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | |
| 9 | + | ||
| 10 | + | func (c Cell) Equal(other Cell) bool { | |
| 11 | + | return c.Rune == other.Rune && c.Style.Equal(other.Style) | |
| 12 | + | } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | type Cell struct { | |||
| 4 | + | Rune rune | |||
| 5 | + | Style Style | |||
| 6 | + | } | |||
| 7 | + | ||||
| 8 | + | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | |||
| 9 | + | ||||
| 10 | + | func (c Cell) Equal(other Cell) bool { | |||
| 11 | + | return c.Rune == other.Rune && c.Style.Equal(other.Style) | |||
| 12 | + | } | |||
A pkg/renderer/damage.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | type DamageKind int | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | DamageText DamageKind = iota | |
| 7 | + | DamageClear | |
| 8 | + | DamageScrollUp | |
| 9 | + | DamageFullRedraw | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type Damage struct { | |
| 13 | + | Kind DamageKind | |
| 14 | + | X int | |
| 15 | + | Y int | |
| 16 | + | Width int | |
| 17 | + | Height int | |
| 18 | + | Count int | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func FullRedraw() Damage { return Damage{Kind: DamageFullRedraw, Count: 1} } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | type DamageKind int | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | DamageText DamageKind = iota | |||
| 7 | + | DamageClear | |||
| 8 | + | DamageScrollUp | |||
| 9 | + | DamageFullRedraw | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type Damage struct { | |||
| 13 | + | Kind DamageKind | |||
| 14 | + | X int | |||
| 15 | + | Y int | |||
| 16 | + | Width int | |||
| 17 | + | Height int | |||
| 18 | + | Count int | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func FullRedraw() Damage { return Damage{Kind: DamageFullRedraw, Count: 1} } | |||
A pkg/renderer/frame.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import "fmt" | |
| 4 | + | ||
| 5 | + | type Frame struct { | |
| 6 | + | Width int | |
| 7 | + | Height int | |
| 8 | + | Cells []Cell | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | func NewFrame(width, height int) Frame { | |
| 12 | + | cells := make([]Cell, width*height) | |
| 13 | + | for i := range cells { | |
| 14 | + | cells[i] = BlankCell() | |
| 15 | + | } | |
| 16 | + | return Frame{Width: width, Height: height, Cells: cells} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (f Frame) Validate() error { | |
| 20 | + | if f.Width <= 0 || f.Height <= 0 { | |
| 21 | + | return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height) | |
| 22 | + | } | |
| 23 | + | if len(f.Cells) != f.Width*f.Height { | |
| 24 | + | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | |
| 25 | + | } | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (f Frame) At(x, y int) Cell { | |
| 30 | + | return f.Cells[y*f.Width+x] | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (f Frame) Set(x, y int, cell Cell) { | |
| 34 | + | f.Cells[y*f.Width+x] = cell | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import "fmt" | |||
| 4 | + | ||||
| 5 | + | type Frame struct { | |||
| 6 | + | Width int | |||
| 7 | + | Height int | |||
| 8 | + | Cells []Cell | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | func NewFrame(width, height int) Frame { | |||
| 12 | + | cells := make([]Cell, width*height) | |||
| 13 | + | for i := range cells { | |||
| 14 | + | cells[i] = BlankCell() | |||
| 15 | + | } | |||
| 16 | + | return Frame{Width: width, Height: height, Cells: cells} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (f Frame) Validate() error { | |||
| 20 | + | if f.Width <= 0 || f.Height <= 0 { | |||
| 21 | + | return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height) | |||
| 22 | + | } | |||
| 23 | + | if len(f.Cells) != f.Width*f.Height { | |||
| 24 | + | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | |||
| 25 | + | } | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (f Frame) At(x, y int) Cell { | |||
| 30 | + | return f.Cells[y*f.Width+x] | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (f Frame) Set(x, y int, cell Cell) { | |||
| 34 | + | f.Cells[y*f.Width+x] = cell | |||
| 35 | + | } | |||
A pkg/renderer/renderer.go +307 -0
| @@ -0,0 +1,307 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | "sync" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const maxPooledBufferCap = 1 << 20 | |
| 10 | + | ||
| 11 | + | var bufferPool = sync.Pool{ | |
| 12 | + | New: func() any { return new(bytes.Buffer) }, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | type Capabilities struct { | |
| 16 | + | SynchronizedOutput bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type Renderer struct { | |
| 20 | + | caps Capabilities | |
| 21 | + | width int | |
| 22 | + | height int | |
| 23 | + | shadow []Cell | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | |
| 27 | + | ||
| 28 | + | func (r *Renderer) Reset() { | |
| 29 | + | r.width = 0 | |
| 30 | + | r.height = 0 | |
| 31 | + | r.shadow = nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |
| 35 | + | if err := frame.Validate(); err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Unchanged frame no-op: same dimensions, shadow populated, no damage, no dirty lines. | |
| 40 | + | if r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells) { | |
| 41 | + | if len(damage) == 0 && !r.hasDirtyLines(frame) { | |
| 42 | + | return nil, nil | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | buf := bufferPool.Get().(*bytes.Buffer) | |
| 47 | + | buf.Reset() | |
| 48 | + | defer putBuffer(buf) | |
| 49 | + | ||
| 50 | + | if r.caps.SynchronizedOutput { | |
| 51 | + | buf.WriteString(SyncStartCSI) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) { | |
| 55 | + | r.writeFull(buf, frame) | |
| 56 | + | r.replaceShadow(frame) | |
| 57 | + | if r.caps.SynchronizedOutput { | |
| 58 | + | buf.WriteString(SyncEndCSI) | |
| 59 | + | } | |
| 60 | + | return copyBytes(buf), nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) { | |
| 64 | + | emitScrollUp(buf, scroll) | |
| 65 | + | r.applyScroll(scroll) | |
| 66 | + | r.writeDamage(buf, frame, damage, &scroll) | |
| 67 | + | r.syncDamage(frame, damage, &scroll) | |
| 68 | + | if r.caps.SynchronizedOutput { | |
| 69 | + | buf.WriteString(SyncEndCSI) | |
| 70 | + | } | |
| 71 | + | return copyBytes(buf), nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Scroll damage changes rows outside the explicit text/clear rectangles. | |
| 75 | + | // If the fast path is not safe, redraw the frame instead of applying | |
| 76 | + | // partial damage and leaving the terminal/shadow stale. | |
| 77 | + | if hasScrollDamage(damage) { | |
| 78 | + | r.writeFull(buf, frame) | |
| 79 | + | r.replaceShadow(frame) | |
| 80 | + | if r.caps.SynchronizedOutput { | |
| 81 | + | buf.WriteString(SyncEndCSI) | |
| 82 | + | } | |
| 83 | + | return copyBytes(buf), nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | r.writeDamage(buf, frame, damage, nil) | |
| 87 | + | r.syncDamage(frame, damage, nil) | |
| 88 | + | if r.caps.SynchronizedOutput { | |
| 89 | + | buf.WriteString(SyncEndCSI) | |
| 90 | + | } | |
| 91 | + | return copyBytes(buf), nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // copyBytes copies the buffer contents into a fresh byte slice and is used | |
| 95 | + | // to return output that is independent of the pooled scratch buffer. | |
| 96 | + | func copyBytes(buf *bytes.Buffer) []byte { | |
| 97 | + | b := buf.Bytes() | |
| 98 | + | out := make([]byte, len(b)) | |
| 99 | + | copy(out, b) | |
| 100 | + | return out | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func putBuffer(buf *bytes.Buffer) { | |
| 104 | + | if buf.Cap() > maxPooledBufferCap { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | bufferPool.Put(buf) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (r *Renderer) hasDirtyLines(frame Frame) bool { | |
| 111 | + | for y := 0; y < frame.Height; y++ { | |
| 112 | + | if r.lineDirty(frame, y) { | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func needsFull(damage []Damage) bool { | |
| 120 | + | if len(damage) == 0 { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | for _, d := range damage { | |
| 124 | + | if d.Kind == DamageFullRedraw { | |
| 125 | + | return true | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func hasScrollDamage(damage []Damage) bool { | |
| 132 | + | for _, d := range damage { | |
| 133 | + | if d.Kind == DamageScrollUp { | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return false | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame) { | |
| 141 | + | style := DefaultStyle() | |
| 142 | + | for y := 0; y < frame.Height; y++ { | |
| 143 | + | writeCursor(out, y, 0) | |
| 144 | + | r.writeRun(out, frame, y, 0, frame.Width, &style) | |
| 145 | + | } | |
| 146 | + | out.WriteString("\x1b[0m") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage) { | |
| 150 | + | style := DefaultStyle() | |
| 151 | + | if len(damage) == 0 { | |
| 152 | + | for y := 0; y < frame.Height; y++ { | |
| 153 | + | if r.lineDirty(frame, y) { | |
| 154 | + | writeCursor(out, y, 0) | |
| 155 | + | r.writeRun(out, frame, y, 0, frame.Width, &style) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | out.WriteString("\x1b[0m") | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | for _, d := range damage { | |
| 162 | + | if skip != nil && sameDamage(d, *skip) { | |
| 163 | + | continue | |
| 164 | + | } | |
| 165 | + | switch d.Kind { | |
| 166 | + | case DamageText, DamageClear: | |
| 167 | + | x, y, w, h, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |
| 168 | + | if !ok { | |
| 169 | + | continue | |
| 170 | + | } | |
| 171 | + | for row := y; row < y+h; row++ { | |
| 172 | + | writeCursor(out, row, x) | |
| 173 | + | r.writeRun(out, frame, row, x, w, &style) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | out.WriteString("\x1b[0m") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, style *Style) { | |
| 181 | + | for col := x; col < x+width; col++ { | |
| 182 | + | cell := frame.At(col, y) | |
| 183 | + | if !cell.Style.Equal(*style) { | |
| 184 | + | writeStyle(out, cell.Style) | |
| 185 | + | *style = cell.Style | |
| 186 | + | } | |
| 187 | + | if cell.Rune == 0 { | |
| 188 | + | out.WriteByte(' ') | |
| 189 | + | } else { | |
| 190 | + | out.WriteRune(cell.Rune) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (r *Renderer) lineDirty(frame Frame, y int) bool { | |
| 196 | + | start := y * frame.Width | |
| 197 | + | for i := 0; i < frame.Width; i++ { | |
| 198 | + | if !r.shadow[start+i].Equal(frame.Cells[start+i]) { | |
| 199 | + | return true | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | return false | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (r *Renderer) replaceShadow(frame Frame) { | |
| 206 | + | r.width = frame.Width | |
| 207 | + | r.height = frame.Height | |
| 208 | + | r.shadow = append(r.shadow[:0], frame.Cells...) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) { | |
| 212 | + | if len(damage) == 0 { | |
| 213 | + | r.replaceShadow(frame) | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | if scroll != nil { | |
| 217 | + | r.syncRect(frame, scroll.X, scroll.Y+scroll.Height-scroll.Count, scroll.Width, scroll.Count) | |
| 218 | + | } | |
| 219 | + | for _, d := range damage { | |
| 220 | + | switch d.Kind { | |
| 221 | + | case DamageText, DamageClear: | |
| 222 | + | r.syncRect(frame, d.X, d.Y, d.Width, d.Height) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (r *Renderer) syncRect(frame Frame, x, y, width, height int) { | |
| 228 | + | x, y, width, height, ok := clampRect(frame, x, y, width, height) | |
| 229 | + | if !ok { | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | for row := y; row < y+height; row++ { | |
| 233 | + | start := row*frame.Width + x | |
| 234 | + | copy(r.shadow[start:start+width], frame.Cells[start:start+width]) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) { | |
| 239 | + | if width <= 0 || height <= 0 { | |
| 240 | + | return 0, 0, 0, 0, false | |
| 241 | + | } | |
| 242 | + | x0 := max(0, x) | |
| 243 | + | y0 := max(0, y) | |
| 244 | + | x1 := min(frame.Width, x+width) | |
| 245 | + | y1 := min(frame.Height, y+height) | |
| 246 | + | if x1 <= x0 || y1 <= y0 { | |
| 247 | + | return 0, 0, 0, 0, false | |
| 248 | + | } | |
| 249 | + | return x0, y0, x1 - x0, y1 - y0, true | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf | |
| 253 | + | // allocations. It uses a stack-allocated buffer for integer formatting. | |
| 254 | + | func writeCursor(out *bytes.Buffer, y, x int) { | |
| 255 | + | out.WriteString("\x1b[") | |
| 256 | + | var b [16]byte | |
| 257 | + | n := strconv.AppendInt(b[:0], int64(y+1), 10) | |
| 258 | + | out.Write(n) | |
| 259 | + | out.WriteByte(';') | |
| 260 | + | n = strconv.AppendInt(b[:0], int64(x+1), 10) | |
| 261 | + | out.Write(n) | |
| 262 | + | out.WriteByte('H') | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // writeStyle emits SGR style parameters without fmt.Fprintf allocations. | |
| 266 | + | func writeStyle(out *bytes.Buffer, style Style) { | |
| 267 | + | out.WriteString("\x1b[0") | |
| 268 | + | if style.Bold { | |
| 269 | + | out.WriteString(";1") | |
| 270 | + | } | |
| 271 | + | if style.Inverse { | |
| 272 | + | out.WriteString(";7") | |
| 273 | + | } | |
| 274 | + | var b [16]byte | |
| 275 | + | if style.HasForegroundRGB { | |
| 276 | + | out.WriteString(";38;2;") | |
| 277 | + | writeRGB(out, &b, style.ForegroundRGB) | |
| 278 | + | } else if style.Foreground >= 0 { | |
| 279 | + | out.WriteString(";38;5;") | |
| 280 | + | n := strconv.AppendInt(b[:0], int64(style.Foreground), 10) | |
| 281 | + | out.Write(n) | |
| 282 | + | } | |
| 283 | + | if style.HasBackgroundRGB { | |
| 284 | + | out.WriteString(";48;2;") | |
| 285 | + | writeRGB(out, &b, style.BackgroundRGB) | |
| 286 | + | } else if style.Background >= 0 { | |
| 287 | + | out.WriteString(";48;5;") | |
| 288 | + | n := strconv.AppendInt(b[:0], int64(style.Background), 10) | |
| 289 | + | out.Write(n) | |
| 290 | + | } | |
| 291 | + | out.WriteByte('m') | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) { | |
| 295 | + | n := strconv.AppendInt(b[:0], int64(rgb.R), 10) | |
| 296 | + | out.Write(n) | |
| 297 | + | out.WriteByte(';') | |
| 298 | + | n = strconv.AppendInt(b[:0], int64(rgb.G), 10) | |
| 299 | + | out.Write(n) | |
| 300 | + | out.WriteByte(';') | |
| 301 | + | n = strconv.AppendInt(b[:0], int64(rgb.B), 10) | |
| 302 | + | out.Write(n) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func sameDamage(a, b Damage) bool { | |
| 306 | + | return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count | |
| 307 | + | } | |
| @@ -0,0 +1,307 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | "sync" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const maxPooledBufferCap = 1 << 20 | |||
| 10 | + | ||||
| 11 | + | var bufferPool = sync.Pool{ | |||
| 12 | + | New: func() any { return new(bytes.Buffer) }, | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | type Capabilities struct { | |||
| 16 | + | SynchronizedOutput bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type Renderer struct { | |||
| 20 | + | caps Capabilities | |||
| 21 | + | width int | |||
| 22 | + | height int | |||
| 23 | + | shadow []Cell | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | |||
| 27 | + | ||||
| 28 | + | func (r *Renderer) Reset() { | |||
| 29 | + | r.width = 0 | |||
| 30 | + | r.height = 0 | |||
| 31 | + | r.shadow = nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 35 | + | if err := frame.Validate(); err != nil { | |||
| 36 | + | return nil, err | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Unchanged frame no-op: same dimensions, shadow populated, no damage, no dirty lines. | |||
| 40 | + | if r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells) { | |||
| 41 | + | if len(damage) == 0 && !r.hasDirtyLines(frame) { | |||
| 42 | + | return nil, nil | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | buf := bufferPool.Get().(*bytes.Buffer) | |||
| 47 | + | buf.Reset() | |||
| 48 | + | defer putBuffer(buf) | |||
| 49 | + | ||||
| 50 | + | if r.caps.SynchronizedOutput { | |||
| 51 | + | buf.WriteString(SyncStartCSI) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) { | |||
| 55 | + | r.writeFull(buf, frame) | |||
| 56 | + | r.replaceShadow(frame) | |||
| 57 | + | if r.caps.SynchronizedOutput { | |||
| 58 | + | buf.WriteString(SyncEndCSI) | |||
| 59 | + | } | |||
| 60 | + | return copyBytes(buf), nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) { | |||
| 64 | + | emitScrollUp(buf, scroll) | |||
| 65 | + | r.applyScroll(scroll) | |||
| 66 | + | r.writeDamage(buf, frame, damage, &scroll) | |||
| 67 | + | r.syncDamage(frame, damage, &scroll) | |||
| 68 | + | if r.caps.SynchronizedOutput { | |||
| 69 | + | buf.WriteString(SyncEndCSI) | |||
| 70 | + | } | |||
| 71 | + | return copyBytes(buf), nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Scroll damage changes rows outside the explicit text/clear rectangles. | |||
| 75 | + | // If the fast path is not safe, redraw the frame instead of applying | |||
| 76 | + | // partial damage and leaving the terminal/shadow stale. | |||
| 77 | + | if hasScrollDamage(damage) { | |||
| 78 | + | r.writeFull(buf, frame) | |||
| 79 | + | r.replaceShadow(frame) | |||
| 80 | + | if r.caps.SynchronizedOutput { | |||
| 81 | + | buf.WriteString(SyncEndCSI) | |||
| 82 | + | } | |||
| 83 | + | return copyBytes(buf), nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | r.writeDamage(buf, frame, damage, nil) | |||
| 87 | + | r.syncDamage(frame, damage, nil) | |||
| 88 | + | if r.caps.SynchronizedOutput { | |||
| 89 | + | buf.WriteString(SyncEndCSI) | |||
| 90 | + | } | |||
| 91 | + | return copyBytes(buf), nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // copyBytes copies the buffer contents into a fresh byte slice and is used | |||
| 95 | + | // to return output that is independent of the pooled scratch buffer. | |||
| 96 | + | func copyBytes(buf *bytes.Buffer) []byte { | |||
| 97 | + | b := buf.Bytes() | |||
| 98 | + | out := make([]byte, len(b)) | |||
| 99 | + | copy(out, b) | |||
| 100 | + | return out | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func putBuffer(buf *bytes.Buffer) { | |||
| 104 | + | if buf.Cap() > maxPooledBufferCap { | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | bufferPool.Put(buf) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (r *Renderer) hasDirtyLines(frame Frame) bool { | |||
| 111 | + | for y := 0; y < frame.Height; y++ { | |||
| 112 | + | if r.lineDirty(frame, y) { | |||
| 113 | + | return true | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return false | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func needsFull(damage []Damage) bool { | |||
| 120 | + | if len(damage) == 0 { | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | for _, d := range damage { | |||
| 124 | + | if d.Kind == DamageFullRedraw { | |||
| 125 | + | return true | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func hasScrollDamage(damage []Damage) bool { | |||
| 132 | + | for _, d := range damage { | |||
| 133 | + | if d.Kind == DamageScrollUp { | |||
| 134 | + | return true | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | return false | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame) { | |||
| 141 | + | style := DefaultStyle() | |||
| 142 | + | for y := 0; y < frame.Height; y++ { | |||
| 143 | + | writeCursor(out, y, 0) | |||
| 144 | + | r.writeRun(out, frame, y, 0, frame.Width, &style) | |||
| 145 | + | } | |||
| 146 | + | out.WriteString("\x1b[0m") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage) { | |||
| 150 | + | style := DefaultStyle() | |||
| 151 | + | if len(damage) == 0 { | |||
| 152 | + | for y := 0; y < frame.Height; y++ { | |||
| 153 | + | if r.lineDirty(frame, y) { | |||
| 154 | + | writeCursor(out, y, 0) | |||
| 155 | + | r.writeRun(out, frame, y, 0, frame.Width, &style) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | out.WriteString("\x1b[0m") | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | for _, d := range damage { | |||
| 162 | + | if skip != nil && sameDamage(d, *skip) { | |||
| 163 | + | continue | |||
| 164 | + | } | |||
| 165 | + | switch d.Kind { | |||
| 166 | + | case DamageText, DamageClear: | |||
| 167 | + | x, y, w, h, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |||
| 168 | + | if !ok { | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | for row := y; row < y+h; row++ { | |||
| 172 | + | writeCursor(out, row, x) | |||
| 173 | + | r.writeRun(out, frame, row, x, w, &style) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | out.WriteString("\x1b[0m") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (r *Renderer) writeRun(out *bytes.Buffer, frame Frame, y, x, width int, style *Style) { | |||
| 181 | + | for col := x; col < x+width; col++ { | |||
| 182 | + | cell := frame.At(col, y) | |||
| 183 | + | if !cell.Style.Equal(*style) { | |||
| 184 | + | writeStyle(out, cell.Style) | |||
| 185 | + | *style = cell.Style | |||
| 186 | + | } | |||
| 187 | + | if cell.Rune == 0 { | |||
| 188 | + | out.WriteByte(' ') | |||
| 189 | + | } else { | |||
| 190 | + | out.WriteRune(cell.Rune) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||
| 196 | + | start := y * frame.Width | |||
| 197 | + | for i := 0; i < frame.Width; i++ { | |||
| 198 | + | if !r.shadow[start+i].Equal(frame.Cells[start+i]) { | |||
| 199 | + | return true | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | return false | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (r *Renderer) replaceShadow(frame Frame) { | |||
| 206 | + | r.width = frame.Width | |||
| 207 | + | r.height = frame.Height | |||
| 208 | + | r.shadow = append(r.shadow[:0], frame.Cells...) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) { | |||
| 212 | + | if len(damage) == 0 { | |||
| 213 | + | r.replaceShadow(frame) | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | if scroll != nil { | |||
| 217 | + | r.syncRect(frame, scroll.X, scroll.Y+scroll.Height-scroll.Count, scroll.Width, scroll.Count) | |||
| 218 | + | } | |||
| 219 | + | for _, d := range damage { | |||
| 220 | + | switch d.Kind { | |||
| 221 | + | case DamageText, DamageClear: | |||
| 222 | + | r.syncRect(frame, d.X, d.Y, d.Width, d.Height) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (r *Renderer) syncRect(frame Frame, x, y, width, height int) { | |||
| 228 | + | x, y, width, height, ok := clampRect(frame, x, y, width, height) | |||
| 229 | + | if !ok { | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | for row := y; row < y+height; row++ { | |||
| 233 | + | start := row*frame.Width + x | |||
| 234 | + | copy(r.shadow[start:start+width], frame.Cells[start:start+width]) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) { | |||
| 239 | + | if width <= 0 || height <= 0 { | |||
| 240 | + | return 0, 0, 0, 0, false | |||
| 241 | + | } | |||
| 242 | + | x0 := max(0, x) | |||
| 243 | + | y0 := max(0, y) | |||
| 244 | + | x1 := min(frame.Width, x+width) | |||
| 245 | + | y1 := min(frame.Height, y+height) | |||
| 246 | + | if x1 <= x0 || y1 <= y0 { | |||
| 247 | + | return 0, 0, 0, 0, false | |||
| 248 | + | } | |||
| 249 | + | return x0, y0, x1 - x0, y1 - y0, true | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf | |||
| 253 | + | // allocations. It uses a stack-allocated buffer for integer formatting. | |||
| 254 | + | func writeCursor(out *bytes.Buffer, y, x int) { | |||
| 255 | + | out.WriteString("\x1b[") | |||
| 256 | + | var b [16]byte | |||
| 257 | + | n := strconv.AppendInt(b[:0], int64(y+1), 10) | |||
| 258 | + | out.Write(n) | |||
| 259 | + | out.WriteByte(';') | |||
| 260 | + | n = strconv.AppendInt(b[:0], int64(x+1), 10) | |||
| 261 | + | out.Write(n) | |||
| 262 | + | out.WriteByte('H') | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // writeStyle emits SGR style parameters without fmt.Fprintf allocations. | |||
| 266 | + | func writeStyle(out *bytes.Buffer, style Style) { | |||
| 267 | + | out.WriteString("\x1b[0") | |||
| 268 | + | if style.Bold { | |||
| 269 | + | out.WriteString(";1") | |||
| 270 | + | } | |||
| 271 | + | if style.Inverse { | |||
| 272 | + | out.WriteString(";7") | |||
| 273 | + | } | |||
| 274 | + | var b [16]byte | |||
| 275 | + | if style.HasForegroundRGB { | |||
| 276 | + | out.WriteString(";38;2;") | |||
| 277 | + | writeRGB(out, &b, style.ForegroundRGB) | |||
| 278 | + | } else if style.Foreground >= 0 { | |||
| 279 | + | out.WriteString(";38;5;") | |||
| 280 | + | n := strconv.AppendInt(b[:0], int64(style.Foreground), 10) | |||
| 281 | + | out.Write(n) | |||
| 282 | + | } | |||
| 283 | + | if style.HasBackgroundRGB { | |||
| 284 | + | out.WriteString(";48;2;") | |||
| 285 | + | writeRGB(out, &b, style.BackgroundRGB) | |||
| 286 | + | } else if style.Background >= 0 { | |||
| 287 | + | out.WriteString(";48;5;") | |||
| 288 | + | n := strconv.AppendInt(b[:0], int64(style.Background), 10) | |||
| 289 | + | out.Write(n) | |||
| 290 | + | } | |||
| 291 | + | out.WriteByte('m') | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) { | |||
| 295 | + | n := strconv.AppendInt(b[:0], int64(rgb.R), 10) | |||
| 296 | + | out.Write(n) | |||
| 297 | + | out.WriteByte(';') | |||
| 298 | + | n = strconv.AppendInt(b[:0], int64(rgb.G), 10) | |||
| 299 | + | out.Write(n) | |||
| 300 | + | out.WriteByte(';') | |||
| 301 | + | n = strconv.AppendInt(b[:0], int64(rgb.B), 10) | |||
| 302 | + | out.Write(n) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func sameDamage(a, b Damage) bool { | |||
| 306 | + | return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count | |||
| 307 | + | } | |||
A pkg/renderer/renderer_test.go +770 -0
| @@ -0,0 +1,770 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // markFrame writes uniform printable content so each row is distinguishable. | |
| 12 | + | func markFrame(f *Frame) { | |
| 13 | + | for y := 0; y < f.Height; y++ { | |
| 14 | + | for x := 0; x < f.Width; x++ { | |
| 15 | + | f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()}) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // outputContains checks that the output contains all the given substrings. | |
| 21 | + | func outputContains(t *testing.T, data []byte, subs ...string) { | |
| 22 | + | t.Helper() | |
| 23 | + | s := string(data) | |
| 24 | + | for _, sub := range subs { | |
| 25 | + | if !strings.Contains(s, sub) { | |
| 26 | + | t.Errorf("output should contain %q", sub) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func outputEndsWith(t *testing.T, data []byte, suffix string) { | |
| 32 | + | t.Helper() | |
| 33 | + | s := string(data) | |
| 34 | + | if !strings.HasSuffix(s, suffix) { | |
| 35 | + | t.Errorf("output should end with %q, got %q", suffix, s) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // --------------------------------------------------------------------------- | |
| 40 | + | // First draw | |
| 41 | + | // --------------------------------------------------------------------------- | |
| 42 | + | ||
| 43 | + | func TestFirstDraw(t *testing.T) { | |
| 44 | + | r := New(Capabilities{}) | |
| 45 | + | frame := NewFrame(5, 3) | |
| 46 | + | markFrame(&frame) | |
| 47 | + | ||
| 48 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | if len(out) == 0 { | |
| 53 | + | t.Fatal("expected non-empty output on first draw") | |
| 54 | + | } | |
| 55 | + | outputContains(t, out, "\x1b[0m") | |
| 56 | + | // Should have cursor-position sequences for all three rows. | |
| 57 | + | if c := strings.Count(string(out), "\x1b["); c < 3 { | |
| 58 | + | t.Fatalf("expected at least 3 CSI sequences, got %d", c) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestFirstDrawFullRedraw(t *testing.T) { | |
| 63 | + | r := New(Capabilities{}) | |
| 64 | + | frame := NewFrame(5, 3) | |
| 65 | + | markFrame(&frame) | |
| 66 | + | ||
| 67 | + | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | if len(out) == 0 { | |
| 72 | + | t.Fatal("expected non-empty output on full redraw") | |
| 73 | + | } | |
| 74 | + | outputContains(t, out, "\x1b[0m") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // --------------------------------------------------------------------------- | |
| 78 | + | // No-op | |
| 79 | + | // --------------------------------------------------------------------------- | |
| 80 | + | ||
| 81 | + | func TestNoOp(t *testing.T) { | |
| 82 | + | r := New(Capabilities{}) | |
| 83 | + | frame := NewFrame(5, 3) | |
| 84 | + | markFrame(&frame) | |
| 85 | + | ||
| 86 | + | // First draw – populate shadow. | |
| 87 | + | out1, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | if len(out1) == 0 { | |
| 92 | + | t.Fatal("first draw must produce output") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Second draw with no changes. | |
| 96 | + | out2, err := r.Draw(frame, nil) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | if len(out2) != 0 { | |
| 101 | + | t.Fatalf("expected no-op (empty output), got %q", string(out2)) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestNoOpWithEmptyDamage(t *testing.T) { | |
| 106 | + | r := New(Capabilities{}) | |
| 107 | + | frame := NewFrame(3, 2) | |
| 108 | + | markFrame(&frame) | |
| 109 | + | ||
| 110 | + | // Populate shadow. | |
| 111 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Same frame, empty damage slice. | |
| 117 | + | out, err := r.Draw(frame, []Damage{}) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | if len(out) != 0 { | |
| 122 | + | t.Fatalf("expected empty output for no changes, got %q", string(out)) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // --------------------------------------------------------------------------- | |
| 127 | + | // Style reset discipline | |
| 128 | + | // --------------------------------------------------------------------------- | |
| 129 | + | ||
| 130 | + | func TestStyleReset(t *testing.T) { | |
| 131 | + | r := New(Capabilities{}) | |
| 132 | + | frame := NewFrame(4, 2) | |
| 133 | + | ||
| 134 | + | // One styled cell, rest default. | |
| 135 | + | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) | |
| 136 | + | ||
| 137 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 138 | + | if err != nil { | |
| 139 | + | t.Fatal(err) | |
| 140 | + | } | |
| 141 | + | // Must end with style reset. | |
| 142 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestStyleEqualUsesActiveColorMode(t *testing.T) { | |
| 146 | + | left := Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | |
| 147 | + | right := Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | |
| 148 | + | if !left.Equal(right) { | |
| 149 | + | t.Fatal("RGB foreground equality should ignore inactive indexed foreground") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | right.ForegroundRGB.R = 13 | |
| 153 | + | if left.Equal(right) { | |
| 154 | + | t.Fatal("RGB foreground equality should compare active RGB values") | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestRendererEmitsTruecolorSGR(t *testing.T) { | |
| 159 | + | r := New(Capabilities{}) | |
| 160 | + | frame := NewFrame(1, 1) | |
| 161 | + | frame.Set(0, 0, Cell{ | |
| 162 | + | Rune: 'X', | |
| 163 | + | Style: Style{ | |
| 164 | + | Foreground: -1, | |
| 165 | + | Background: -1, | |
| 166 | + | HasForegroundRGB: true, | |
| 167 | + | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | |
| 168 | + | HasBackgroundRGB: true, | |
| 169 | + | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | |
| 170 | + | }, | |
| 171 | + | }) | |
| 172 | + | ||
| 173 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}) | |
| 174 | + | if err != nil { | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | outputContains(t, out, "\x1b[0;38;2;12;34;56;48;2;200;100;50m") | |
| 179 | + | outputContains(t, out, "X") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestRendererEmitsStyleChangesForAdjacentColorModes(t *testing.T) { | |
| 183 | + | r := New(Capabilities{}) | |
| 184 | + | frame := NewFrame(3, 1) | |
| 185 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |
| 186 | + | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |
| 187 | + | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |
| 188 | + | ||
| 189 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | outputContains(t, out, "\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestStyleResetAfterScroll(t *testing.T) { | |
| 198 | + | r := New(Capabilities{}) | |
| 199 | + | frame := NewFrame(4, 3) | |
| 200 | + | markFrame(&frame) | |
| 201 | + | ||
| 202 | + | // Populate shadow. | |
| 203 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |
| 204 | + | if err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |
| 209 | + | frame2 := NewFrame(4, 3) | |
| 210 | + | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |
| 211 | + | for i := 8; i < 12; i++ { | |
| 212 | + | frame2.Cells[i] = BlankCell() // bottom row blank | |
| 213 | + | } | |
| 214 | + | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |
| 215 | + | ||
| 216 | + | damage := []Damage{ | |
| 217 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 218 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | out, err := r.Draw(frame2, damage) | |
| 222 | + | if err != nil { | |
| 223 | + | t.Fatal(err) | |
| 224 | + | } | |
| 225 | + | // The output must end with a style reset (either from emitScrollUp or writeDamage). | |
| 226 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // --------------------------------------------------------------------------- | |
| 230 | + | // Scroll fast path | |
| 231 | + | // --------------------------------------------------------------------------- | |
| 232 | + | ||
| 233 | + | func TestScrollFastPath(t *testing.T) { | |
| 234 | + | r := New(Capabilities{}) | |
| 235 | + | frame := NewFrame(5, 4) | |
| 236 | + | markFrame(&frame) | |
| 237 | + | ||
| 238 | + | // Populate shadow. | |
| 239 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) | |
| 240 | + | if err != nil { | |
| 241 | + | t.Fatal(err) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Build a new frame that has been scrolled up by 1 (like the VT model does). | |
| 245 | + | scrolled := NewFrame(5, 4) | |
| 246 | + | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | |
| 247 | + | for i := 15; i < 20; i++ { | |
| 248 | + | scrolled.Cells[i] = BlankCell() // row 3 blanked | |
| 249 | + | } | |
| 250 | + | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |
| 251 | + | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |
| 252 | + | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | |
| 253 | + | ||
| 254 | + | damage := []Damage{ | |
| 255 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |
| 256 | + | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | out, err := r.Draw(scrolled, damage) | |
| 260 | + | if err != nil { | |
| 261 | + | t.Fatal(err) | |
| 262 | + | } | |
| 263 | + | if len(out) == 0 { | |
| 264 | + | t.Fatal("expected non-empty output for scroll") | |
| 265 | + | } | |
| 266 | + | // Should contain scroll-region sequences. | |
| 267 | + | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |
| 268 | + | outputContains(t, out, "\x1b[r") // restore scroll region | |
| 269 | + | // Should contain the new text on the exposed row. | |
| 270 | + | outputContains(t, out, "N") | |
| 271 | + | outputContains(t, out, "e") | |
| 272 | + | outputContains(t, out, "w") | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // --------------------------------------------------------------------------- | |
| 276 | + | // Synchronized output wrapper | |
| 277 | + | // --------------------------------------------------------------------------- | |
| 278 | + | ||
| 279 | + | func TestSynchronizedOutput(t *testing.T) { | |
| 280 | + | r := New(Capabilities{SynchronizedOutput: true}) | |
| 281 | + | frame := NewFrame(3, 2) | |
| 282 | + | markFrame(&frame) | |
| 283 | + | ||
| 284 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 285 | + | if err != nil { | |
| 286 | + | t.Fatal(err) | |
| 287 | + | } | |
| 288 | + | if !strings.HasPrefix(string(out), SyncStartCSI) { | |
| 289 | + | t.Errorf("expected output to start with sync start CSI") | |
| 290 | + | } | |
| 291 | + | if !strings.HasSuffix(string(out), SyncEndCSI) { | |
| 292 | + | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestSynchronizedOutputNoOp(t *testing.T) { | |
| 297 | + | r := New(Capabilities{SynchronizedOutput: true}) | |
| 298 | + | frame := NewFrame(3, 2) | |
| 299 | + | markFrame(&frame) | |
| 300 | + | ||
| 301 | + | // First draw. | |
| 302 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 303 | + | if err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // No-op – must return nil, not wrapped empty output. | |
| 308 | + | out, err := r.Draw(frame, nil) | |
| 309 | + | if err != nil { | |
| 310 | + | t.Fatal(err) | |
| 311 | + | } | |
| 312 | + | if len(out) != 0 { | |
| 313 | + | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // --------------------------------------------------------------------------- | |
| 318 | + | // WrapSynchronized standalone function | |
| 319 | + | // --------------------------------------------------------------------------- | |
| 320 | + | ||
| 321 | + | func TestWrapSynchronized(t *testing.T) { | |
| 322 | + | content := []byte("\x1b[0mhello") | |
| 323 | + | wrapped := WrapSynchronized(content, true) | |
| 324 | + | if !strings.HasPrefix(string(wrapped), SyncStartCSI) { | |
| 325 | + | t.Errorf("missing sync start") | |
| 326 | + | } | |
| 327 | + | if !strings.HasSuffix(string(wrapped), SyncEndCSI) { | |
| 328 | + | t.Errorf("missing sync end") | |
| 329 | + | } | |
| 330 | + | if !strings.Contains(string(wrapped), "hello") { | |
| 331 | + | t.Errorf("missing original content") | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // disabled | |
| 335 | + | plain := WrapSynchronized(content, false) | |
| 336 | + | if string(plain) != string(content) { | |
| 337 | + | t.Errorf("disabled wrapping should return content unchanged") | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // empty input | |
| 341 | + | empty := WrapSynchronized(nil, true) | |
| 342 | + | if len(empty) != 0 { | |
| 343 | + | t.Errorf("empty input should return empty") | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // --------------------------------------------------------------------------- | |
| 348 | + | // Partial damage draw | |
| 349 | + | // --------------------------------------------------------------------------- | |
| 350 | + | ||
| 351 | + | func TestPartialDamage(t *testing.T) { | |
| 352 | + | r := New(Capabilities{}) | |
| 353 | + | frame := NewFrame(4, 2) | |
| 354 | + | markFrame(&frame) | |
| 355 | + | ||
| 356 | + | // Populate shadow. | |
| 357 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 358 | + | if err != nil { | |
| 359 | + | t.Fatal(err) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // Change one cell. | |
| 363 | + | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 364 | + | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} | |
| 365 | + | ||
| 366 | + | out, err := r.Draw(frame, damage) | |
| 367 | + | if err != nil { | |
| 368 | + | t.Fatal(err) | |
| 369 | + | } | |
| 370 | + | if len(out) == 0 { | |
| 371 | + | t.Fatal("expected non-empty output for partial damage") | |
| 372 | + | } | |
| 373 | + | // Should position cursor at (2,1) — 1-indexed (3;2). | |
| 374 | + | outputContains(t, out, "\x1b[2;3H") | |
| 375 | + | outputContains(t, out, "Z") | |
| 376 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // --------------------------------------------------------------------------- | |
| 380 | + | // Scheduler coalescing | |
| 381 | + | // --------------------------------------------------------------------------- | |
| 382 | + | ||
| 383 | + | func TestSchedulerCoalescing(t *testing.T) { | |
| 384 | + | s := NewScheduler(30 * time.Millisecond) | |
| 385 | + | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |
| 386 | + | defer cancel() | |
| 387 | + | ||
| 388 | + | frames := s.Run(ctx) | |
| 389 | + | ||
| 390 | + | // Fire several requests before the timer fires. | |
| 391 | + | s.Request() | |
| 392 | + | s.Request() | |
| 393 | + | s.Request() | |
| 394 | + | ||
| 395 | + | // Expect exactly one frame. | |
| 396 | + | select { | |
| 397 | + | case _, ok := <-frames: | |
| 398 | + | if !ok { | |
| 399 | + | t.Fatal("channel closed unexpectedly") | |
| 400 | + | } | |
| 401 | + | case <-ctx.Done(): | |
| 402 | + | t.Fatal("timeout waiting for frame") | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // No second frame should arrive without another request. | |
| 406 | + | select { | |
| 407 | + | case <-frames: | |
| 408 | + | t.Fatal("unexpected second frame") | |
| 409 | + | case <-time.After(80 * time.Millisecond): | |
| 410 | + | // good – coalesced. | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestSchedulerMultipleFrames(t *testing.T) { | |
| 415 | + | s := NewScheduler(10 * time.Millisecond) | |
| 416 | + | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | |
| 417 | + | defer cancel() | |
| 418 | + | ||
| 419 | + | frames := s.Run(ctx) | |
| 420 | + | ||
| 421 | + | s.Request() | |
| 422 | + | <-frames // consume first | |
| 423 | + | ||
| 424 | + | s.Request() | |
| 425 | + | <-frames // consume second | |
| 426 | + | ||
| 427 | + | // No pending request – should not deliver another frame. | |
| 428 | + | select { | |
| 429 | + | case <-frames: | |
| 430 | + | t.Fatal("unexpected frame without request") | |
| 431 | + | case <-time.After(40 * time.Millisecond): | |
| 432 | + | // good | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func TestSchedulerContextCancellation(t *testing.T) { | |
| 437 | + | s := NewScheduler(50 * time.Millisecond) | |
| 438 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 439 | + | ||
| 440 | + | frames := s.Run(ctx) | |
| 441 | + | s.Request() | |
| 442 | + | ||
| 443 | + | // Cancel before the timer fires. | |
| 444 | + | cancel() | |
| 445 | + | ||
| 446 | + | // Channel should close without ever delivering a frame. | |
| 447 | + | select { | |
| 448 | + | case _, ok := <-frames: | |
| 449 | + | if ok { | |
| 450 | + | // A frame may have been emitted before cancellation took effect; | |
| 451 | + | // that is acceptable. What matters is that the goroutine exits. | |
| 452 | + | _ = ok | |
| 453 | + | } | |
| 454 | + | case <-time.After(200 * time.Millisecond): | |
| 455 | + | // Channel didn't close – goroutine may be stuck. | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // --------------------------------------------------------------------------- | |
| 460 | + | // Reset | |
| 461 | + | // --------------------------------------------------------------------------- | |
| 462 | + | ||
| 463 | + | func TestReset(t *testing.T) { | |
| 464 | + | r := New(Capabilities{}) | |
| 465 | + | frame := NewFrame(3, 2) | |
| 466 | + | markFrame(&frame) | |
| 467 | + | ||
| 468 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 469 | + | if err != nil { | |
| 470 | + | t.Fatal(err) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | r.Reset() | |
| 474 | + | ||
| 475 | + | if r.width != 0 || r.height != 0 || r.shadow != nil { | |
| 476 | + | t.Fatal("Reset should clear width/height/shadow") | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // After reset, the next draw should be a full draw. | |
| 480 | + | out, err := r.Draw(frame, nil) | |
| 481 | + | if err != nil { | |
| 482 | + | t.Fatal(err) | |
| 483 | + | } | |
| 484 | + | if len(out) == 0 { | |
| 485 | + | t.Fatal("expected non-empty output after reset") | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // --------------------------------------------------------------------------- | |
| 490 | + | // Damage helper | |
| 491 | + | // --------------------------------------------------------------------------- | |
| 492 | + | ||
| 493 | + | func TestFullRedraw(t *testing.T) { | |
| 494 | + | r := New(Capabilities{}) | |
| 495 | + | frame := NewFrame(3, 2) | |
| 496 | + | markFrame(&frame) | |
| 497 | + | ||
| 498 | + | // Populate. | |
| 499 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 500 | + | if err != nil { | |
| 501 | + | t.Fatal(err) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | // FullRedraw forces full output even with same frame. | |
| 505 | + | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 506 | + | if err != nil { | |
| 507 | + | t.Fatal(err) | |
| 508 | + | } | |
| 509 | + | if len(out) == 0 { | |
| 510 | + | t.Fatal("expected output for FullRedraw") | |
| 511 | + | } | |
| 512 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | // --------------------------------------------------------------------------- | |
| 516 | + | // Scroll with no remaining damage | |
| 517 | + | // --------------------------------------------------------------------------- | |
| 518 | + | ||
| 519 | + | func TestScrollOnlyDamage(t *testing.T) { | |
| 520 | + | r := New(Capabilities{}) | |
| 521 | + | frame := NewFrame(4, 3) | |
| 522 | + | markFrame(&frame) | |
| 523 | + | ||
| 524 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |
| 525 | + | if err != nil { | |
| 526 | + | t.Fatal(err) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | |
| 530 | + | scrolled := NewFrame(4, 3) | |
| 531 | + | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |
| 532 | + | for i := 8; i < 12; i++ { | |
| 533 | + | scrolled.Cells[i] = BlankCell() | |
| 534 | + | } | |
| 535 | + | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 536 | + | ||
| 537 | + | damage := []Damage{ | |
| 538 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 539 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | out, err := r.Draw(scrolled, damage) | |
| 543 | + | if err != nil { | |
| 544 | + | t.Fatal(err) | |
| 545 | + | } | |
| 546 | + | if len(out) == 0 { | |
| 547 | + | t.Fatal("expected output for scroll") | |
| 548 | + | } | |
| 549 | + | // Should include scroll region | |
| 550 | + | outputContains(t, out, "\x1b[1;3r") | |
| 551 | + | outputContains(t, out, "\x1b[r") | |
| 552 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // --------------------------------------------------------------------------- | |
| 556 | + | // writeCursor output format | |
| 557 | + | // --------------------------------------------------------------------------- | |
| 558 | + | ||
| 559 | + | func TestWriteCursor(t *testing.T) { | |
| 560 | + | var buf bytes.Buffer | |
| 561 | + | writeCursor(&buf, 0, 0) | |
| 562 | + | if buf.String() != "\x1b[1;1H" { | |
| 563 | + | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |
| 564 | + | } | |
| 565 | + | buf.Reset() | |
| 566 | + | writeCursor(&buf, 2, 5) | |
| 567 | + | if buf.String() != "\x1b[3;6H" { | |
| 568 | + | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | // --------------------------------------------------------------------------- | |
| 573 | + | // isSafeScroll edge cases | |
| 574 | + | // --------------------------------------------------------------------------- | |
| 575 | + | ||
| 576 | + | func TestIsSafeScroll(t *testing.T) { | |
| 577 | + | frame := NewFrame(10, 8) | |
| 578 | + | ||
| 579 | + | tests := []struct { | |
| 580 | + | name string | |
| 581 | + | d Damage | |
| 582 | + | want bool | |
| 583 | + | }{ | |
| 584 | + | {"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true}, | |
| 585 | + | {"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false}, | |
| 586 | + | {"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false}, | |
| 587 | + | {"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false}, | |
| 588 | + | {"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false}, | |
| 589 | + | {"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false}, | |
| 590 | + | {"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false}, | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | for _, tt := range tests { | |
| 594 | + | t.Run(tt.name, func(t *testing.T) { | |
| 595 | + | got := isSafeScroll(frame, tt.d) | |
| 596 | + | if got != tt.want { | |
| 597 | + | t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want) | |
| 598 | + | } | |
| 599 | + | }) | |
| 600 | + | } | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | // --------------------------------------------------------------------------- | |
| 604 | + | // BlankCell / Cell.Equal | |
| 605 | + | // --------------------------------------------------------------------------- | |
| 606 | + | ||
| 607 | + | func TestCellEqual(t *testing.T) { | |
| 608 | + | a := BlankCell() | |
| 609 | + | b := BlankCell() | |
| 610 | + | if !a.Equal(b) { | |
| 611 | + | t.Error("blank cells should be equal") | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | a.Rune = 'A' | |
| 615 | + | if a.Equal(b) { | |
| 616 | + | t.Error("different runes should not be equal") | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | a = BlankCell() | |
| 620 | + | a.Style.Bold = true | |
| 621 | + | if a.Equal(b) { | |
| 622 | + | t.Error("different styles should not be equal") | |
| 623 | + | } | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | // --------------------------------------------------------------------------- | |
| 627 | + | // Frame helpers | |
| 628 | + | // --------------------------------------------------------------------------- | |
| 629 | + | ||
| 630 | + | func TestFrameValidate(t *testing.T) { | |
| 631 | + | // Valid frame. | |
| 632 | + | f := NewFrame(5, 3) | |
| 633 | + | if err := f.Validate(); err != nil { | |
| 634 | + | t.Fatalf("unexpected error: %v", err) | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // Invalid dimensions. | |
| 638 | + | f2 := Frame{Width: 0, Height: 5} | |
| 639 | + | if err := f2.Validate(); err == nil { | |
| 640 | + | t.Fatal("expected error for zero width") | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | // Wrong cell count. | |
| 644 | + | f3 := Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)} | |
| 645 | + | if err := f3.Validate(); err == nil { | |
| 646 | + | t.Fatal("expected error for wrong cell count") | |
| 647 | + | } | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | func TestFrameAtSet(t *testing.T) { | |
| 651 | + | f := NewFrame(3, 2) | |
| 652 | + | cell := Cell{Rune: 'X', Style: Style{Bold: true}} | |
| 653 | + | f.Set(1, 1, cell) | |
| 654 | + | ||
| 655 | + | got := f.At(1, 1) | |
| 656 | + | if got.Rune != 'X' || !got.Style.Bold { | |
| 657 | + | t.Errorf("At/Set round-trip failed: got %+v", got) | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | // Unaffected cells are blank. | |
| 661 | + | got = f.At(0, 0) | |
| 662 | + | if got != BlankCell() { | |
| 663 | + | t.Errorf("unexpected cell at (0,0): %+v", got) | |
| 664 | + | } | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // --------------------------------------------------------------------------- | |
| 668 | + | // Damage helpers | |
| 669 | + | // --------------------------------------------------------------------------- | |
| 670 | + | ||
| 671 | + | func TestSameDamage(t *testing.T) { | |
| 672 | + | a := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | |
| 673 | + | b := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | |
| 674 | + | if !sameDamage(a, b) { | |
| 675 | + | t.Error("identical damages should be equal") | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | c := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0} | |
| 679 | + | if sameDamage(a, c) { | |
| 680 | + | t.Error("different counts should not be equal") | |
| 681 | + | } | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | func TestDamageCoversCell(t *testing.T) { | |
| 685 | + | damage := []Damage{ | |
| 686 | + | {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2}, | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | if !damageCoversCell(damage, 3, 3) { | |
| 690 | + | t.Error("should cover (3,3)") | |
| 691 | + | } | |
| 692 | + | if !damageCoversCell(damage, 6, 4) { | |
| 693 | + | t.Error("should cover (6,4)") | |
| 694 | + | } | |
| 695 | + | if damageCoversCell(damage, 1, 3) { | |
| 696 | + | t.Error("should not cover (1,3)") | |
| 697 | + | } | |
| 698 | + | if damageCoversCell(damage, 3, 5) { | |
| 699 | + | t.Error("should not cover (3,5)") | |
| 700 | + | } | |
| 701 | + | if damageCoversCell(damage, 7, 3) { | |
| 702 | + | t.Error("should not cover (7,3)") | |
| 703 | + | } | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | // --------------------------------------------------------------------------- | |
| 707 | + | // needsFull | |
| 708 | + | // --------------------------------------------------------------------------- | |
| 709 | + | ||
| 710 | + | func TestNeedsFull(t *testing.T) { | |
| 711 | + | if needsFull(nil) { | |
| 712 | + | t.Error("nil damage should not need full") | |
| 713 | + | } | |
| 714 | + | if needsFull([]Damage{}) { | |
| 715 | + | t.Error("empty damage should not need full") | |
| 716 | + | } | |
| 717 | + | if needsFull([]Damage{{Kind: DamageText}}) { | |
| 718 | + | t.Error("text damage should not need full") | |
| 719 | + | } | |
| 720 | + | if !needsFull([]Damage{FullRedraw()}) { | |
| 721 | + | t.Error("FullRedraw should need full") | |
| 722 | + | } | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | func TestScrollDamageFallbackFullRedrawWhenFastPathUnsafe(t *testing.T) { | |
| 726 | + | r := New(Capabilities{}) | |
| 727 | + | frame := NewFrame(4, 3) | |
| 728 | + | markFrame(&frame) | |
| 729 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { | |
| 730 | + | t.Fatal(err) | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | changed := NewFrame(4, 3) | |
| 734 | + | markFrame(&changed) | |
| 735 | + | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship | |
| 736 | + | damage := []Damage{ | |
| 737 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 738 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 739 | + | } | |
| 740 | + | out, err := r.Draw(changed, damage) | |
| 741 | + | if err != nil { | |
| 742 | + | t.Fatal(err) | |
| 743 | + | } | |
| 744 | + | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |
| 745 | + | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |
| 746 | + | } | |
| 747 | + | for i := range changed.Cells { | |
| 748 | + | if !r.shadow[i].Equal(changed.Cells[i]) { | |
| 749 | + | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |
| 750 | + | } | |
| 751 | + | } | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | func TestDamageRectanglesAreClamped(t *testing.T) { | |
| 755 | + | r := New(Capabilities{}) | |
| 756 | + | frame := NewFrame(3, 2) | |
| 757 | + | markFrame(&frame) | |
| 758 | + | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |
| 759 | + | t.Fatal(err) | |
| 760 | + | } | |
| 761 | + | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 762 | + | defer func() { | |
| 763 | + | if r := recover(); r != nil { | |
| 764 | + | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |
| 765 | + | } | |
| 766 | + | }() | |
| 767 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |
| 768 | + | t.Fatal(err) | |
| 769 | + | } | |
| 770 | + | } | |
| @@ -0,0 +1,770 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // markFrame writes uniform printable content so each row is distinguishable. | |||
| 12 | + | func markFrame(f *Frame) { | |||
| 13 | + | for y := 0; y < f.Height; y++ { | |||
| 14 | + | for x := 0; x < f.Width; x++ { | |||
| 15 | + | f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()}) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // outputContains checks that the output contains all the given substrings. | |||
| 21 | + | func outputContains(t *testing.T, data []byte, subs ...string) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | s := string(data) | |||
| 24 | + | for _, sub := range subs { | |||
| 25 | + | if !strings.Contains(s, sub) { | |||
| 26 | + | t.Errorf("output should contain %q", sub) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func outputEndsWith(t *testing.T, data []byte, suffix string) { | |||
| 32 | + | t.Helper() | |||
| 33 | + | s := string(data) | |||
| 34 | + | if !strings.HasSuffix(s, suffix) { | |||
| 35 | + | t.Errorf("output should end with %q, got %q", suffix, s) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // --------------------------------------------------------------------------- | |||
| 40 | + | // First draw | |||
| 41 | + | // --------------------------------------------------------------------------- | |||
| 42 | + | ||||
| 43 | + | func TestFirstDraw(t *testing.T) { | |||
| 44 | + | r := New(Capabilities{}) | |||
| 45 | + | frame := NewFrame(5, 3) | |||
| 46 | + | markFrame(&frame) | |||
| 47 | + | ||||
| 48 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | if len(out) == 0 { | |||
| 53 | + | t.Fatal("expected non-empty output on first draw") | |||
| 54 | + | } | |||
| 55 | + | outputContains(t, out, "\x1b[0m") | |||
| 56 | + | // Should have cursor-position sequences for all three rows. | |||
| 57 | + | if c := strings.Count(string(out), "\x1b["); c < 3 { | |||
| 58 | + | t.Fatalf("expected at least 3 CSI sequences, got %d", c) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestFirstDrawFullRedraw(t *testing.T) { | |||
| 63 | + | r := New(Capabilities{}) | |||
| 64 | + | frame := NewFrame(5, 3) | |||
| 65 | + | markFrame(&frame) | |||
| 66 | + | ||||
| 67 | + | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | if len(out) == 0 { | |||
| 72 | + | t.Fatal("expected non-empty output on full redraw") | |||
| 73 | + | } | |||
| 74 | + | outputContains(t, out, "\x1b[0m") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // --------------------------------------------------------------------------- | |||
| 78 | + | // No-op | |||
| 79 | + | // --------------------------------------------------------------------------- | |||
| 80 | + | ||||
| 81 | + | func TestNoOp(t *testing.T) { | |||
| 82 | + | r := New(Capabilities{}) | |||
| 83 | + | frame := NewFrame(5, 3) | |||
| 84 | + | markFrame(&frame) | |||
| 85 | + | ||||
| 86 | + | // First draw – populate shadow. | |||
| 87 | + | out1, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | if len(out1) == 0 { | |||
| 92 | + | t.Fatal("first draw must produce output") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Second draw with no changes. | |||
| 96 | + | out2, err := r.Draw(frame, nil) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | if len(out2) != 0 { | |||
| 101 | + | t.Fatalf("expected no-op (empty output), got %q", string(out2)) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestNoOpWithEmptyDamage(t *testing.T) { | |||
| 106 | + | r := New(Capabilities{}) | |||
| 107 | + | frame := NewFrame(3, 2) | |||
| 108 | + | markFrame(&frame) | |||
| 109 | + | ||||
| 110 | + | // Populate shadow. | |||
| 111 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Same frame, empty damage slice. | |||
| 117 | + | out, err := r.Draw(frame, []Damage{}) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | if len(out) != 0 { | |||
| 122 | + | t.Fatalf("expected empty output for no changes, got %q", string(out)) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // --------------------------------------------------------------------------- | |||
| 127 | + | // Style reset discipline | |||
| 128 | + | // --------------------------------------------------------------------------- | |||
| 129 | + | ||||
| 130 | + | func TestStyleReset(t *testing.T) { | |||
| 131 | + | r := New(Capabilities{}) | |||
| 132 | + | frame := NewFrame(4, 2) | |||
| 133 | + | ||||
| 134 | + | // One styled cell, rest default. | |||
| 135 | + | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) | |||
| 136 | + | ||||
| 137 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |||
| 138 | + | if err != nil { | |||
| 139 | + | t.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | // Must end with style reset. | |||
| 142 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestStyleEqualUsesActiveColorMode(t *testing.T) { | |||
| 146 | + | left := Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | |||
| 147 | + | right := Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | |||
| 148 | + | if !left.Equal(right) { | |||
| 149 | + | t.Fatal("RGB foreground equality should ignore inactive indexed foreground") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | right.ForegroundRGB.R = 13 | |||
| 153 | + | if left.Equal(right) { | |||
| 154 | + | t.Fatal("RGB foreground equality should compare active RGB values") | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestRendererEmitsTruecolorSGR(t *testing.T) { | |||
| 159 | + | r := New(Capabilities{}) | |||
| 160 | + | frame := NewFrame(1, 1) | |||
| 161 | + | frame.Set(0, 0, Cell{ | |||
| 162 | + | Rune: 'X', | |||
| 163 | + | Style: Style{ | |||
| 164 | + | Foreground: -1, | |||
| 165 | + | Background: -1, | |||
| 166 | + | HasForegroundRGB: true, | |||
| 167 | + | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | |||
| 168 | + | HasBackgroundRGB: true, | |||
| 169 | + | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | |||
| 170 | + | }, | |||
| 171 | + | }) | |||
| 172 | + | ||||
| 173 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}) | |||
| 174 | + | if err != nil { | |||
| 175 | + | t.Fatal(err) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | outputContains(t, out, "\x1b[0;38;2;12;34;56;48;2;200;100;50m") | |||
| 179 | + | outputContains(t, out, "X") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestRendererEmitsStyleChangesForAdjacentColorModes(t *testing.T) { | |||
| 183 | + | r := New(Capabilities{}) | |||
| 184 | + | frame := NewFrame(3, 1) | |||
| 185 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |||
| 186 | + | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |||
| 187 | + | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |||
| 188 | + | ||||
| 189 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}) | |||
| 190 | + | if err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | outputContains(t, out, "\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD") | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestStyleResetAfterScroll(t *testing.T) { | |||
| 198 | + | r := New(Capabilities{}) | |||
| 199 | + | frame := NewFrame(4, 3) | |||
| 200 | + | markFrame(&frame) | |||
| 201 | + | ||||
| 202 | + | // Populate shadow. | |||
| 203 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |||
| 204 | + | if err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |||
| 209 | + | frame2 := NewFrame(4, 3) | |||
| 210 | + | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |||
| 211 | + | for i := 8; i < 12; i++ { | |||
| 212 | + | frame2.Cells[i] = BlankCell() // bottom row blank | |||
| 213 | + | } | |||
| 214 | + | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |||
| 215 | + | ||||
| 216 | + | damage := []Damage{ | |||
| 217 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 218 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | out, err := r.Draw(frame2, damage) | |||
| 222 | + | if err != nil { | |||
| 223 | + | t.Fatal(err) | |||
| 224 | + | } | |||
| 225 | + | // The output must end with a style reset (either from emitScrollUp or writeDamage). | |||
| 226 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // --------------------------------------------------------------------------- | |||
| 230 | + | // Scroll fast path | |||
| 231 | + | // --------------------------------------------------------------------------- | |||
| 232 | + | ||||
| 233 | + | func TestScrollFastPath(t *testing.T) { | |||
| 234 | + | r := New(Capabilities{}) | |||
| 235 | + | frame := NewFrame(5, 4) | |||
| 236 | + | markFrame(&frame) | |||
| 237 | + | ||||
| 238 | + | // Populate shadow. | |||
| 239 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) | |||
| 240 | + | if err != nil { | |||
| 241 | + | t.Fatal(err) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Build a new frame that has been scrolled up by 1 (like the VT model does). | |||
| 245 | + | scrolled := NewFrame(5, 4) | |||
| 246 | + | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | |||
| 247 | + | for i := 15; i < 20; i++ { | |||
| 248 | + | scrolled.Cells[i] = BlankCell() // row 3 blanked | |||
| 249 | + | } | |||
| 250 | + | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |||
| 251 | + | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |||
| 252 | + | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | |||
| 253 | + | ||||
| 254 | + | damage := []Damage{ | |||
| 255 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |||
| 256 | + | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | out, err := r.Draw(scrolled, damage) | |||
| 260 | + | if err != nil { | |||
| 261 | + | t.Fatal(err) | |||
| 262 | + | } | |||
| 263 | + | if len(out) == 0 { | |||
| 264 | + | t.Fatal("expected non-empty output for scroll") | |||
| 265 | + | } | |||
| 266 | + | // Should contain scroll-region sequences. | |||
| 267 | + | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |||
| 268 | + | outputContains(t, out, "\x1b[r") // restore scroll region | |||
| 269 | + | // Should contain the new text on the exposed row. | |||
| 270 | + | outputContains(t, out, "N") | |||
| 271 | + | outputContains(t, out, "e") | |||
| 272 | + | outputContains(t, out, "w") | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // --------------------------------------------------------------------------- | |||
| 276 | + | // Synchronized output wrapper | |||
| 277 | + | // --------------------------------------------------------------------------- | |||
| 278 | + | ||||
| 279 | + | func TestSynchronizedOutput(t *testing.T) { | |||
| 280 | + | r := New(Capabilities{SynchronizedOutput: true}) | |||
| 281 | + | frame := NewFrame(3, 2) | |||
| 282 | + | markFrame(&frame) | |||
| 283 | + | ||||
| 284 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 285 | + | if err != nil { | |||
| 286 | + | t.Fatal(err) | |||
| 287 | + | } | |||
| 288 | + | if !strings.HasPrefix(string(out), SyncStartCSI) { | |||
| 289 | + | t.Errorf("expected output to start with sync start CSI") | |||
| 290 | + | } | |||
| 291 | + | if !strings.HasSuffix(string(out), SyncEndCSI) { | |||
| 292 | + | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestSynchronizedOutputNoOp(t *testing.T) { | |||
| 297 | + | r := New(Capabilities{SynchronizedOutput: true}) | |||
| 298 | + | frame := NewFrame(3, 2) | |||
| 299 | + | markFrame(&frame) | |||
| 300 | + | ||||
| 301 | + | // First draw. | |||
| 302 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 303 | + | if err != nil { | |||
| 304 | + | t.Fatal(err) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // No-op – must return nil, not wrapped empty output. | |||
| 308 | + | out, err := r.Draw(frame, nil) | |||
| 309 | + | if err != nil { | |||
| 310 | + | t.Fatal(err) | |||
| 311 | + | } | |||
| 312 | + | if len(out) != 0 { | |||
| 313 | + | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // --------------------------------------------------------------------------- | |||
| 318 | + | // WrapSynchronized standalone function | |||
| 319 | + | // --------------------------------------------------------------------------- | |||
| 320 | + | ||||
| 321 | + | func TestWrapSynchronized(t *testing.T) { | |||
| 322 | + | content := []byte("\x1b[0mhello") | |||
| 323 | + | wrapped := WrapSynchronized(content, true) | |||
| 324 | + | if !strings.HasPrefix(string(wrapped), SyncStartCSI) { | |||
| 325 | + | t.Errorf("missing sync start") | |||
| 326 | + | } | |||
| 327 | + | if !strings.HasSuffix(string(wrapped), SyncEndCSI) { | |||
| 328 | + | t.Errorf("missing sync end") | |||
| 329 | + | } | |||
| 330 | + | if !strings.Contains(string(wrapped), "hello") { | |||
| 331 | + | t.Errorf("missing original content") | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // disabled | |||
| 335 | + | plain := WrapSynchronized(content, false) | |||
| 336 | + | if string(plain) != string(content) { | |||
| 337 | + | t.Errorf("disabled wrapping should return content unchanged") | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // empty input | |||
| 341 | + | empty := WrapSynchronized(nil, true) | |||
| 342 | + | if len(empty) != 0 { | |||
| 343 | + | t.Errorf("empty input should return empty") | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // --------------------------------------------------------------------------- | |||
| 348 | + | // Partial damage draw | |||
| 349 | + | // --------------------------------------------------------------------------- | |||
| 350 | + | ||||
| 351 | + | func TestPartialDamage(t *testing.T) { | |||
| 352 | + | r := New(Capabilities{}) | |||
| 353 | + | frame := NewFrame(4, 2) | |||
| 354 | + | markFrame(&frame) | |||
| 355 | + | ||||
| 356 | + | // Populate shadow. | |||
| 357 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |||
| 358 | + | if err != nil { | |||
| 359 | + | t.Fatal(err) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // Change one cell. | |||
| 363 | + | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 364 | + | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} | |||
| 365 | + | ||||
| 366 | + | out, err := r.Draw(frame, damage) | |||
| 367 | + | if err != nil { | |||
| 368 | + | t.Fatal(err) | |||
| 369 | + | } | |||
| 370 | + | if len(out) == 0 { | |||
| 371 | + | t.Fatal("expected non-empty output for partial damage") | |||
| 372 | + | } | |||
| 373 | + | // Should position cursor at (2,1) — 1-indexed (3;2). | |||
| 374 | + | outputContains(t, out, "\x1b[2;3H") | |||
| 375 | + | outputContains(t, out, "Z") | |||
| 376 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // --------------------------------------------------------------------------- | |||
| 380 | + | // Scheduler coalescing | |||
| 381 | + | // --------------------------------------------------------------------------- | |||
| 382 | + | ||||
| 383 | + | func TestSchedulerCoalescing(t *testing.T) { | |||
| 384 | + | s := NewScheduler(30 * time.Millisecond) | |||
| 385 | + | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |||
| 386 | + | defer cancel() | |||
| 387 | + | ||||
| 388 | + | frames := s.Run(ctx) | |||
| 389 | + | ||||
| 390 | + | // Fire several requests before the timer fires. | |||
| 391 | + | s.Request() | |||
| 392 | + | s.Request() | |||
| 393 | + | s.Request() | |||
| 394 | + | ||||
| 395 | + | // Expect exactly one frame. | |||
| 396 | + | select { | |||
| 397 | + | case _, ok := <-frames: | |||
| 398 | + | if !ok { | |||
| 399 | + | t.Fatal("channel closed unexpectedly") | |||
| 400 | + | } | |||
| 401 | + | case <-ctx.Done(): | |||
| 402 | + | t.Fatal("timeout waiting for frame") | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // No second frame should arrive without another request. | |||
| 406 | + | select { | |||
| 407 | + | case <-frames: | |||
| 408 | + | t.Fatal("unexpected second frame") | |||
| 409 | + | case <-time.After(80 * time.Millisecond): | |||
| 410 | + | // good – coalesced. | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func TestSchedulerMultipleFrames(t *testing.T) { | |||
| 415 | + | s := NewScheduler(10 * time.Millisecond) | |||
| 416 | + | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | |||
| 417 | + | defer cancel() | |||
| 418 | + | ||||
| 419 | + | frames := s.Run(ctx) | |||
| 420 | + | ||||
| 421 | + | s.Request() | |||
| 422 | + | <-frames // consume first | |||
| 423 | + | ||||
| 424 | + | s.Request() | |||
| 425 | + | <-frames // consume second | |||
| 426 | + | ||||
| 427 | + | // No pending request – should not deliver another frame. | |||
| 428 | + | select { | |||
| 429 | + | case <-frames: | |||
| 430 | + | t.Fatal("unexpected frame without request") | |||
| 431 | + | case <-time.After(40 * time.Millisecond): | |||
| 432 | + | // good | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func TestSchedulerContextCancellation(t *testing.T) { | |||
| 437 | + | s := NewScheduler(50 * time.Millisecond) | |||
| 438 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 439 | + | ||||
| 440 | + | frames := s.Run(ctx) | |||
| 441 | + | s.Request() | |||
| 442 | + | ||||
| 443 | + | // Cancel before the timer fires. | |||
| 444 | + | cancel() | |||
| 445 | + | ||||
| 446 | + | // Channel should close without ever delivering a frame. | |||
| 447 | + | select { | |||
| 448 | + | case _, ok := <-frames: | |||
| 449 | + | if ok { | |||
| 450 | + | // A frame may have been emitted before cancellation took effect; | |||
| 451 | + | // that is acceptable. What matters is that the goroutine exits. | |||
| 452 | + | _ = ok | |||
| 453 | + | } | |||
| 454 | + | case <-time.After(200 * time.Millisecond): | |||
| 455 | + | // Channel didn't close – goroutine may be stuck. | |||
| 456 | + | } | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // --------------------------------------------------------------------------- | |||
| 460 | + | // Reset | |||
| 461 | + | // --------------------------------------------------------------------------- | |||
| 462 | + | ||||
| 463 | + | func TestReset(t *testing.T) { | |||
| 464 | + | r := New(Capabilities{}) | |||
| 465 | + | frame := NewFrame(3, 2) | |||
| 466 | + | markFrame(&frame) | |||
| 467 | + | ||||
| 468 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 469 | + | if err != nil { | |||
| 470 | + | t.Fatal(err) | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | r.Reset() | |||
| 474 | + | ||||
| 475 | + | if r.width != 0 || r.height != 0 || r.shadow != nil { | |||
| 476 | + | t.Fatal("Reset should clear width/height/shadow") | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | // After reset, the next draw should be a full draw. | |||
| 480 | + | out, err := r.Draw(frame, nil) | |||
| 481 | + | if err != nil { | |||
| 482 | + | t.Fatal(err) | |||
| 483 | + | } | |||
| 484 | + | if len(out) == 0 { | |||
| 485 | + | t.Fatal("expected non-empty output after reset") | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // --------------------------------------------------------------------------- | |||
| 490 | + | // Damage helper | |||
| 491 | + | // --------------------------------------------------------------------------- | |||
| 492 | + | ||||
| 493 | + | func TestFullRedraw(t *testing.T) { | |||
| 494 | + | r := New(Capabilities{}) | |||
| 495 | + | frame := NewFrame(3, 2) | |||
| 496 | + | markFrame(&frame) | |||
| 497 | + | ||||
| 498 | + | // Populate. | |||
| 499 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 500 | + | if err != nil { | |||
| 501 | + | t.Fatal(err) | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | // FullRedraw forces full output even with same frame. | |||
| 505 | + | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 506 | + | if err != nil { | |||
| 507 | + | t.Fatal(err) | |||
| 508 | + | } | |||
| 509 | + | if len(out) == 0 { | |||
| 510 | + | t.Fatal("expected output for FullRedraw") | |||
| 511 | + | } | |||
| 512 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | // --------------------------------------------------------------------------- | |||
| 516 | + | // Scroll with no remaining damage | |||
| 517 | + | // --------------------------------------------------------------------------- | |||
| 518 | + | ||||
| 519 | + | func TestScrollOnlyDamage(t *testing.T) { | |||
| 520 | + | r := New(Capabilities{}) | |||
| 521 | + | frame := NewFrame(4, 3) | |||
| 522 | + | markFrame(&frame) | |||
| 523 | + | ||||
| 524 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |||
| 525 | + | if err != nil { | |||
| 526 | + | t.Fatal(err) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | |||
| 530 | + | scrolled := NewFrame(4, 3) | |||
| 531 | + | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |||
| 532 | + | for i := 8; i < 12; i++ { | |||
| 533 | + | scrolled.Cells[i] = BlankCell() | |||
| 534 | + | } | |||
| 535 | + | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 536 | + | ||||
| 537 | + | damage := []Damage{ | |||
| 538 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 539 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | out, err := r.Draw(scrolled, damage) | |||
| 543 | + | if err != nil { | |||
| 544 | + | t.Fatal(err) | |||
| 545 | + | } | |||
| 546 | + | if len(out) == 0 { | |||
| 547 | + | t.Fatal("expected output for scroll") | |||
| 548 | + | } | |||
| 549 | + | // Should include scroll region | |||
| 550 | + | outputContains(t, out, "\x1b[1;3r") | |||
| 551 | + | outputContains(t, out, "\x1b[r") | |||
| 552 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | // --------------------------------------------------------------------------- | |||
| 556 | + | // writeCursor output format | |||
| 557 | + | // --------------------------------------------------------------------------- | |||
| 558 | + | ||||
| 559 | + | func TestWriteCursor(t *testing.T) { | |||
| 560 | + | var buf bytes.Buffer | |||
| 561 | + | writeCursor(&buf, 0, 0) | |||
| 562 | + | if buf.String() != "\x1b[1;1H" { | |||
| 563 | + | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |||
| 564 | + | } | |||
| 565 | + | buf.Reset() | |||
| 566 | + | writeCursor(&buf, 2, 5) | |||
| 567 | + | if buf.String() != "\x1b[3;6H" { | |||
| 568 | + | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | // --------------------------------------------------------------------------- | |||
| 573 | + | // isSafeScroll edge cases | |||
| 574 | + | // --------------------------------------------------------------------------- | |||
| 575 | + | ||||
| 576 | + | func TestIsSafeScroll(t *testing.T) { | |||
| 577 | + | frame := NewFrame(10, 8) | |||
| 578 | + | ||||
| 579 | + | tests := []struct { | |||
| 580 | + | name string | |||
| 581 | + | d Damage | |||
| 582 | + | want bool | |||
| 583 | + | }{ | |||
| 584 | + | {"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true}, | |||
| 585 | + | {"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false}, | |||
| 586 | + | {"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false}, | |||
| 587 | + | {"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false}, | |||
| 588 | + | {"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false}, | |||
| 589 | + | {"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false}, | |||
| 590 | + | {"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false}, | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | for _, tt := range tests { | |||
| 594 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 595 | + | got := isSafeScroll(frame, tt.d) | |||
| 596 | + | if got != tt.want { | |||
| 597 | + | t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want) | |||
| 598 | + | } | |||
| 599 | + | }) | |||
| 600 | + | } | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | // --------------------------------------------------------------------------- | |||
| 604 | + | // BlankCell / Cell.Equal | |||
| 605 | + | // --------------------------------------------------------------------------- | |||
| 606 | + | ||||
| 607 | + | func TestCellEqual(t *testing.T) { | |||
| 608 | + | a := BlankCell() | |||
| 609 | + | b := BlankCell() | |||
| 610 | + | if !a.Equal(b) { | |||
| 611 | + | t.Error("blank cells should be equal") | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | a.Rune = 'A' | |||
| 615 | + | if a.Equal(b) { | |||
| 616 | + | t.Error("different runes should not be equal") | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | a = BlankCell() | |||
| 620 | + | a.Style.Bold = true | |||
| 621 | + | if a.Equal(b) { | |||
| 622 | + | t.Error("different styles should not be equal") | |||
| 623 | + | } | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | // --------------------------------------------------------------------------- | |||
| 627 | + | // Frame helpers | |||
| 628 | + | // --------------------------------------------------------------------------- | |||
| 629 | + | ||||
| 630 | + | func TestFrameValidate(t *testing.T) { | |||
| 631 | + | // Valid frame. | |||
| 632 | + | f := NewFrame(5, 3) | |||
| 633 | + | if err := f.Validate(); err != nil { | |||
| 634 | + | t.Fatalf("unexpected error: %v", err) | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // Invalid dimensions. | |||
| 638 | + | f2 := Frame{Width: 0, Height: 5} | |||
| 639 | + | if err := f2.Validate(); err == nil { | |||
| 640 | + | t.Fatal("expected error for zero width") | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | // Wrong cell count. | |||
| 644 | + | f3 := Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)} | |||
| 645 | + | if err := f3.Validate(); err == nil { | |||
| 646 | + | t.Fatal("expected error for wrong cell count") | |||
| 647 | + | } | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | func TestFrameAtSet(t *testing.T) { | |||
| 651 | + | f := NewFrame(3, 2) | |||
| 652 | + | cell := Cell{Rune: 'X', Style: Style{Bold: true}} | |||
| 653 | + | f.Set(1, 1, cell) | |||
| 654 | + | ||||
| 655 | + | got := f.At(1, 1) | |||
| 656 | + | if got.Rune != 'X' || !got.Style.Bold { | |||
| 657 | + | t.Errorf("At/Set round-trip failed: got %+v", got) | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | // Unaffected cells are blank. | |||
| 661 | + | got = f.At(0, 0) | |||
| 662 | + | if got != BlankCell() { | |||
| 663 | + | t.Errorf("unexpected cell at (0,0): %+v", got) | |||
| 664 | + | } | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // --------------------------------------------------------------------------- | |||
| 668 | + | // Damage helpers | |||
| 669 | + | // --------------------------------------------------------------------------- | |||
| 670 | + | ||||
| 671 | + | func TestSameDamage(t *testing.T) { | |||
| 672 | + | a := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | |||
| 673 | + | b := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | |||
| 674 | + | if !sameDamage(a, b) { | |||
| 675 | + | t.Error("identical damages should be equal") | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | c := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0} | |||
| 679 | + | if sameDamage(a, c) { | |||
| 680 | + | t.Error("different counts should not be equal") | |||
| 681 | + | } | |||
| 682 | + | } | |||
| 683 | + | ||||
| 684 | + | func TestDamageCoversCell(t *testing.T) { | |||
| 685 | + | damage := []Damage{ | |||
| 686 | + | {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2}, | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | if !damageCoversCell(damage, 3, 3) { | |||
| 690 | + | t.Error("should cover (3,3)") | |||
| 691 | + | } | |||
| 692 | + | if !damageCoversCell(damage, 6, 4) { | |||
| 693 | + | t.Error("should cover (6,4)") | |||
| 694 | + | } | |||
| 695 | + | if damageCoversCell(damage, 1, 3) { | |||
| 696 | + | t.Error("should not cover (1,3)") | |||
| 697 | + | } | |||
| 698 | + | if damageCoversCell(damage, 3, 5) { | |||
| 699 | + | t.Error("should not cover (3,5)") | |||
| 700 | + | } | |||
| 701 | + | if damageCoversCell(damage, 7, 3) { | |||
| 702 | + | t.Error("should not cover (7,3)") | |||
| 703 | + | } | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | // --------------------------------------------------------------------------- | |||
| 707 | + | // needsFull | |||
| 708 | + | // --------------------------------------------------------------------------- | |||
| 709 | + | ||||
| 710 | + | func TestNeedsFull(t *testing.T) { | |||
| 711 | + | if needsFull(nil) { | |||
| 712 | + | t.Error("nil damage should not need full") | |||
| 713 | + | } | |||
| 714 | + | if needsFull([]Damage{}) { | |||
| 715 | + | t.Error("empty damage should not need full") | |||
| 716 | + | } | |||
| 717 | + | if needsFull([]Damage{{Kind: DamageText}}) { | |||
| 718 | + | t.Error("text damage should not need full") | |||
| 719 | + | } | |||
| 720 | + | if !needsFull([]Damage{FullRedraw()}) { | |||
| 721 | + | t.Error("FullRedraw should need full") | |||
| 722 | + | } | |||
| 723 | + | } | |||
| 724 | + | ||||
| 725 | + | func TestScrollDamageFallbackFullRedrawWhenFastPathUnsafe(t *testing.T) { | |||
| 726 | + | r := New(Capabilities{}) | |||
| 727 | + | frame := NewFrame(4, 3) | |||
| 728 | + | markFrame(&frame) | |||
| 729 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { | |||
| 730 | + | t.Fatal(err) | |||
| 731 | + | } | |||
| 732 | + | ||||
| 733 | + | changed := NewFrame(4, 3) | |||
| 734 | + | markFrame(&changed) | |||
| 735 | + | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship | |||
| 736 | + | damage := []Damage{ | |||
| 737 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 738 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 739 | + | } | |||
| 740 | + | out, err := r.Draw(changed, damage) | |||
| 741 | + | if err != nil { | |||
| 742 | + | t.Fatal(err) | |||
| 743 | + | } | |||
| 744 | + | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |||
| 745 | + | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |||
| 746 | + | } | |||
| 747 | + | for i := range changed.Cells { | |||
| 748 | + | if !r.shadow[i].Equal(changed.Cells[i]) { | |||
| 749 | + | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |||
| 750 | + | } | |||
| 751 | + | } | |||
| 752 | + | } | |||
| 753 | + | ||||
| 754 | + | func TestDamageRectanglesAreClamped(t *testing.T) { | |||
| 755 | + | r := New(Capabilities{}) | |||
| 756 | + | frame := NewFrame(3, 2) | |||
| 757 | + | markFrame(&frame) | |||
| 758 | + | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 759 | + | t.Fatal(err) | |||
| 760 | + | } | |||
| 761 | + | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 762 | + | defer func() { | |||
| 763 | + | if r := recover(); r != nil { | |||
| 764 | + | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |||
| 765 | + | } | |||
| 766 | + | }() | |||
| 767 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |||
| 768 | + | t.Fatal(err) | |||
| 769 | + | } | |||
| 770 | + | } | |||
A pkg/renderer/scheduler.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const DefaultFrameInterval = 10 * time.Millisecond | |
| 9 | + | ||
| 10 | + | type Scheduler struct { | |
| 11 | + | interval time.Duration | |
| 12 | + | requests chan struct{} | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func NewScheduler(interval time.Duration) *Scheduler { | |
| 16 | + | if interval <= 0 { | |
| 17 | + | interval = DefaultFrameInterval | |
| 18 | + | } | |
| 19 | + | return &Scheduler{interval: interval, requests: make(chan struct{}, 1)} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (s *Scheduler) Request() { | |
| 23 | + | select { | |
| 24 | + | case s.requests <- struct{}{}: | |
| 25 | + | default: | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (s *Scheduler) Run(ctx context.Context) <-chan struct{} { | |
| 30 | + | frames := make(chan struct{}, 1) | |
| 31 | + | go func() { | |
| 32 | + | defer close(frames) | |
| 33 | + | var timer *time.Timer | |
| 34 | + | var timerC <-chan time.Time | |
| 35 | + | pending := false | |
| 36 | + | for { | |
| 37 | + | select { | |
| 38 | + | case <-ctx.Done(): | |
| 39 | + | if timer != nil { | |
| 40 | + | timer.Stop() | |
| 41 | + | } | |
| 42 | + | return | |
| 43 | + | case <-s.requests: | |
| 44 | + | if pending { | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | pending = true | |
| 48 | + | timer = time.NewTimer(s.interval) | |
| 49 | + | timerC = timer.C | |
| 50 | + | case <-timerC: | |
| 51 | + | pending = false | |
| 52 | + | timerC = nil | |
| 53 | + | select { | |
| 54 | + | case frames <- struct{}{}: | |
| 55 | + | case <-ctx.Done(): | |
| 56 | + | return | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | }() | |
| 61 | + | return frames | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const DefaultFrameInterval = 10 * time.Millisecond | |||
| 9 | + | ||||
| 10 | + | type Scheduler struct { | |||
| 11 | + | interval time.Duration | |||
| 12 | + | requests chan struct{} | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func NewScheduler(interval time.Duration) *Scheduler { | |||
| 16 | + | if interval <= 0 { | |||
| 17 | + | interval = DefaultFrameInterval | |||
| 18 | + | } | |||
| 19 | + | return &Scheduler{interval: interval, requests: make(chan struct{}, 1)} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (s *Scheduler) Request() { | |||
| 23 | + | select { | |||
| 24 | + | case s.requests <- struct{}{}: | |||
| 25 | + | default: | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (s *Scheduler) Run(ctx context.Context) <-chan struct{} { | |||
| 30 | + | frames := make(chan struct{}, 1) | |||
| 31 | + | go func() { | |||
| 32 | + | defer close(frames) | |||
| 33 | + | var timer *time.Timer | |||
| 34 | + | var timerC <-chan time.Time | |||
| 35 | + | pending := false | |||
| 36 | + | for { | |||
| 37 | + | select { | |||
| 38 | + | case <-ctx.Done(): | |||
| 39 | + | if timer != nil { | |||
| 40 | + | timer.Stop() | |||
| 41 | + | } | |||
| 42 | + | return | |||
| 43 | + | case <-s.requests: | |||
| 44 | + | if pending { | |||
| 45 | + | continue | |||
| 46 | + | } | |||
| 47 | + | pending = true | |||
| 48 | + | timer = time.NewTimer(s.interval) | |||
| 49 | + | timerC = timer.C | |||
| 50 | + | case <-timerC: | |||
| 51 | + | pending = false | |||
| 52 | + | timerC = nil | |||
| 53 | + | select { | |||
| 54 | + | case frames <- struct{}{}: | |||
| 55 | + | case <-ctx.Done(): | |||
| 56 | + | return | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | }() | |||
| 61 | + | return frames | |||
| 62 | + | } | |||
A pkg/renderer/scroll.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) { | |
| 9 | + | for _, d := range damage { | |
| 10 | + | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | |
| 11 | + | return d, true | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | return Damage{}, false | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func isSafeScroll(frame Frame, d Damage) bool { | |
| 18 | + | return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Height > 0 && d.Count > 0 && d.Count < d.Height && d.Y+d.Height <= frame.Height | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func emitScrollUp(out *bytes.Buffer, d Damage) { | |
| 22 | + | out.WriteString("\x1b[0m") | |
| 23 | + | // \x1b[%d;%dr — set scroll region | |
| 24 | + | out.WriteString("\x1b[") | |
| 25 | + | var b [16]byte | |
| 26 | + | n := strconv.AppendInt(b[:0], int64(d.Y+1), 10) | |
| 27 | + | out.Write(n) | |
| 28 | + | out.WriteByte(';') | |
| 29 | + | n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10) | |
| 30 | + | out.Write(n) | |
| 31 | + | out.WriteByte('r') | |
| 32 | + | // Position cursor at bottom of scroll region. | |
| 33 | + | writeCursor(out, d.Y+d.Height-1, 0) | |
| 34 | + | if d.Count == 1 { | |
| 35 | + | out.WriteByte('\n') | |
| 36 | + | } else { | |
| 37 | + | out.WriteString("\x1b[") | |
| 38 | + | n = strconv.AppendInt(b[:0], int64(d.Count), 10) | |
| 39 | + | out.Write(n) | |
| 40 | + | out.WriteByte('S') | |
| 41 | + | } | |
| 42 | + | out.WriteString("\x1b[r") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool { | |
| 46 | + | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |
| 47 | + | frameStart := y*frame.Width + scroll.X | |
| 48 | + | shadowStart := (y+scroll.Count)*r.width + scroll.X | |
| 49 | + | for x := 0; x < scroll.Width; x++ { | |
| 50 | + | if r.shadow[shadowStart+x].Equal(frame.Cells[frameStart+x]) { | |
| 51 | + | continue | |
| 52 | + | } | |
| 53 | + | if !damageCoversCell(damage, scroll.X+x, y) { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return true | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r *Renderer) applyScroll(scroll Damage) { | |
| 62 | + | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |
| 63 | + | dst := y*r.width + scroll.X | |
| 64 | + | src := (y+scroll.Count)*r.width + scroll.X | |
| 65 | + | copy(r.shadow[dst:dst+scroll.Width], r.shadow[src:src+scroll.Width]) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func damageCoversCell(damage []Damage, x, y int) bool { | |
| 70 | + | for _, d := range damage { | |
| 71 | + | if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height { | |
| 72 | + | return true | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return false | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) { | |||
| 9 | + | for _, d := range damage { | |||
| 10 | + | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | |||
| 11 | + | return d, true | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | return Damage{}, false | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func isSafeScroll(frame Frame, d Damage) bool { | |||
| 18 | + | return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Height > 0 && d.Count > 0 && d.Count < d.Height && d.Y+d.Height <= frame.Height | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func emitScrollUp(out *bytes.Buffer, d Damage) { | |||
| 22 | + | out.WriteString("\x1b[0m") | |||
| 23 | + | // \x1b[%d;%dr — set scroll region | |||
| 24 | + | out.WriteString("\x1b[") | |||
| 25 | + | var b [16]byte | |||
| 26 | + | n := strconv.AppendInt(b[:0], int64(d.Y+1), 10) | |||
| 27 | + | out.Write(n) | |||
| 28 | + | out.WriteByte(';') | |||
| 29 | + | n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10) | |||
| 30 | + | out.Write(n) | |||
| 31 | + | out.WriteByte('r') | |||
| 32 | + | // Position cursor at bottom of scroll region. | |||
| 33 | + | writeCursor(out, d.Y+d.Height-1, 0) | |||
| 34 | + | if d.Count == 1 { | |||
| 35 | + | out.WriteByte('\n') | |||
| 36 | + | } else { | |||
| 37 | + | out.WriteString("\x1b[") | |||
| 38 | + | n = strconv.AppendInt(b[:0], int64(d.Count), 10) | |||
| 39 | + | out.Write(n) | |||
| 40 | + | out.WriteByte('S') | |||
| 41 | + | } | |||
| 42 | + | out.WriteString("\x1b[r") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool { | |||
| 46 | + | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |||
| 47 | + | frameStart := y*frame.Width + scroll.X | |||
| 48 | + | shadowStart := (y+scroll.Count)*r.width + scroll.X | |||
| 49 | + | for x := 0; x < scroll.Width; x++ { | |||
| 50 | + | if r.shadow[shadowStart+x].Equal(frame.Cells[frameStart+x]) { | |||
| 51 | + | continue | |||
| 52 | + | } | |||
| 53 | + | if !damageCoversCell(damage, scroll.X+x, y) { | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return true | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (r *Renderer) applyScroll(scroll Damage) { | |||
| 62 | + | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |||
| 63 | + | dst := y*r.width + scroll.X | |||
| 64 | + | src := (y+scroll.Count)*r.width + scroll.X | |||
| 65 | + | copy(r.shadow[dst:dst+scroll.Width], r.shadow[src:src+scroll.Width]) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func damageCoversCell(damage []Damage, x, y int) bool { | |||
| 70 | + | for _, d := range damage { | |||
| 71 | + | if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height { | |||
| 72 | + | return true | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return false | |||
| 76 | + | } | |||
A pkg/renderer/style.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | type RGB struct { | |
| 4 | + | R uint8 | |
| 5 | + | G uint8 | |
| 6 | + | B uint8 | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | type Style struct { | |
| 10 | + | Bold bool | |
| 11 | + | Inverse bool | |
| 12 | + | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | |
| 13 | + | Background int // -1 means unset; ignored when HasBackgroundRGB is true | |
| 14 | + | ||
| 15 | + | HasForegroundRGB bool | |
| 16 | + | ForegroundRGB RGB | |
| 17 | + | HasBackgroundRGB bool | |
| 18 | + | BackgroundRGB RGB | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | |
| 22 | + | ||
| 23 | + | func (s Style) Equal(other Style) bool { | |
| 24 | + | if s.Bold != other.Bold || s.Inverse != other.Inverse { | |
| 25 | + | return false | |
| 26 | + | } | |
| 27 | + | if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB { | |
| 28 | + | return false | |
| 29 | + | } | |
| 30 | + | if s.HasForegroundRGB { | |
| 31 | + | if s.ForegroundRGB != other.ForegroundRGB { | |
| 32 | + | return false | |
| 33 | + | } | |
| 34 | + | } else if s.Foreground != other.Foreground { | |
| 35 | + | return false | |
| 36 | + | } | |
| 37 | + | if s.HasBackgroundRGB { | |
| 38 | + | return s.BackgroundRGB == other.BackgroundRGB | |
| 39 | + | } | |
| 40 | + | return s.Background == other.Background | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | type RGB struct { | |||
| 4 | + | R uint8 | |||
| 5 | + | G uint8 | |||
| 6 | + | B uint8 | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | type Style struct { | |||
| 10 | + | Bold bool | |||
| 11 | + | Inverse bool | |||
| 12 | + | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | |||
| 13 | + | Background int // -1 means unset; ignored when HasBackgroundRGB is true | |||
| 14 | + | ||||
| 15 | + | HasForegroundRGB bool | |||
| 16 | + | ForegroundRGB RGB | |||
| 17 | + | HasBackgroundRGB bool | |||
| 18 | + | BackgroundRGB RGB | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | |||
| 22 | + | ||||
| 23 | + | func (s Style) Equal(other Style) bool { | |||
| 24 | + | if s.Bold != other.Bold || s.Inverse != other.Inverse { | |||
| 25 | + | return false | |||
| 26 | + | } | |||
| 27 | + | if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB { | |||
| 28 | + | return false | |||
| 29 | + | } | |||
| 30 | + | if s.HasForegroundRGB { | |||
| 31 | + | if s.ForegroundRGB != other.ForegroundRGB { | |||
| 32 | + | return false | |||
| 33 | + | } | |||
| 34 | + | } else if s.Foreground != other.Foreground { | |||
| 35 | + | return false | |||
| 36 | + | } | |||
| 37 | + | if s.HasBackgroundRGB { | |||
| 38 | + | return s.BackgroundRGB == other.BackgroundRGB | |||
| 39 | + | } | |||
| 40 | + | return s.Background == other.Background | |||
| 41 | + | } | |||
A pkg/renderer/sync_output.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | const ( | |
| 4 | + | SyncStartCSI = "\x1b[?2026h" | |
| 5 | + | SyncEndCSI = "\x1b[?2026l" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func WrapSynchronized(content []byte, enabled bool) []byte { | |
| 9 | + | if !enabled || len(content) == 0 { | |
| 10 | + | return content | |
| 11 | + | } | |
| 12 | + | out := make([]byte, 0, len(SyncStartCSI)+len(content)+len(SyncEndCSI)) | |
| 13 | + | out = append(out, SyncStartCSI...) | |
| 14 | + | out = append(out, content...) | |
| 15 | + | out = append(out, SyncEndCSI...) | |
| 16 | + | return out | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | const ( | |||
| 4 | + | SyncStartCSI = "\x1b[?2026h" | |||
| 5 | + | SyncEndCSI = "\x1b[?2026l" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func WrapSynchronized(content []byte, enabled bool) []byte { | |||
| 9 | + | if !enabled || len(content) == 0 { | |||
| 10 | + | return content | |||
| 11 | + | } | |||
| 12 | + | out := make([]byte, 0, len(SyncStartCSI)+len(content)+len(SyncEndCSI)) | |||
| 13 | + | out = append(out, SyncStartCSI...) | |||
| 14 | + | out = append(out, content...) | |||
| 15 | + | out = append(out, SyncEndCSI...) | |||
| 16 | + | return out | |||
| 17 | + | } | |||
A pkg/vt/benchmark_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | |
| 6 | + | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | |
| 7 | + | b.ReportAllocs() | |
| 8 | + | for b.Loop() { | |
| 9 | + | s := NewScreen(120, 40) | |
| 10 | + | for range 200 { | |
| 11 | + | s.Write(chunk) | |
| 12 | + | s.ClearDamage() | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func BenchmarkScreenFullscreenScrollRegion(b *testing.B) { | |
| 18 | + | chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n") | |
| 19 | + | b.ReportAllocs() | |
| 20 | + | for b.Loop() { | |
| 21 | + | s := NewScreen(120, 40) | |
| 22 | + | for range 500 { | |
| 23 | + | s.Write(chunk) | |
| 24 | + | s.ClearDamage() | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | |||
| 6 | + | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | |||
| 7 | + | b.ReportAllocs() | |||
| 8 | + | for b.Loop() { | |||
| 9 | + | s := NewScreen(120, 40) | |||
| 10 | + | for range 200 { | |||
| 11 | + | s.Write(chunk) | |||
| 12 | + | s.ClearDamage() | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func BenchmarkScreenFullscreenScrollRegion(b *testing.B) { | |||
| 18 | + | chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n") | |||
| 19 | + | b.ReportAllocs() | |||
| 20 | + | for b.Loop() { | |||
| 21 | + | s := NewScreen(120, 40) | |||
| 22 | + | for range 500 { | |||
| 23 | + | s.Write(chunk) | |||
| 24 | + | s.ClearDamage() | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | } | |||
A pkg/vt/damage.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | type Damage = renderer.Damage | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | DamageText = renderer.DamageText | |
| 9 | + | DamageClear = renderer.DamageClear | |
| 10 | + | DamageScrollUp = renderer.DamageScrollUp | |
| 11 | + | DamageFullRedraw = renderer.DamageFullRedraw | |
| 12 | + | ) | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | type Damage = renderer.Damage | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | DamageText = renderer.DamageText | |||
| 9 | + | DamageClear = renderer.DamageClear | |||
| 10 | + | DamageScrollUp = renderer.DamageScrollUp | |||
| 11 | + | DamageFullRedraw = renderer.DamageFullRedraw | |||
| 12 | + | ) | |||
A pkg/vt/screen.go +918 -0
| @@ -0,0 +1,918 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | "strings" | |
| 6 | + | "unicode" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type cursorState struct { | |
| 13 | + | row int | |
| 14 | + | col int | |
| 15 | + | style renderer.Style | |
| 16 | + | saved bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type screenState struct { | |
| 20 | + | frame renderer.Frame | |
| 21 | + | row int | |
| 22 | + | col int | |
| 23 | + | style renderer.Style | |
| 24 | + | scrollTop int | |
| 25 | + | scrollBottom int | |
| 26 | + | savedCursor cursorState | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | const maxEscapeBufferLen = 4096 | |
| 30 | + | ||
| 31 | + | type Screen struct { | |
| 32 | + | Frame renderer.Frame | |
| 33 | + | Row int | |
| 34 | + | Col int | |
| 35 | + | Style renderer.Style | |
| 36 | + | damage []renderer.Damage | |
| 37 | + | escapeBuf []byte | |
| 38 | + | ||
| 39 | + | scrollTop int | |
| 40 | + | scrollBottom int | |
| 41 | + | savedCursor cursorState | |
| 42 | + | alternate *screenState | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func NewScreen(width, height int) *Screen { | |
| 46 | + | s := &Screen{ | |
| 47 | + | Frame: renderer.NewFrame(width, height), | |
| 48 | + | Style: renderer.DefaultStyle(), | |
| 49 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 50 | + | } | |
| 51 | + | s.resetScrollRegion() | |
| 52 | + | return s | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (s *Screen) Resize(width, height int) { | |
| 56 | + | if width == s.Frame.Width && height == s.Frame.Height { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | s.Frame = renderer.NewFrame(width, height) | |
| 60 | + | s.Row, s.Col = 0, 0 | |
| 61 | + | s.Style = renderer.DefaultStyle() | |
| 62 | + | s.escapeBuf = s.escapeBuf[:0] | |
| 63 | + | s.savedCursor = cursorState{} | |
| 64 | + | s.alternate = nil | |
| 65 | + | s.resetScrollRegion() | |
| 66 | + | s.fullRedraw() | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Damage returns the current damage list. The caller must not modify the | |
| 70 | + | // returned slice; ClearDamage must be called after the damage is consumed. | |
| 71 | + | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |
| 72 | + | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | |
| 73 | + | ||
| 74 | + | func (s *Screen) Write(data []byte) { | |
| 75 | + | if len(s.escapeBuf) > 0 { | |
| 76 | + | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |
| 77 | + | combined = append(combined, s.escapeBuf...) | |
| 78 | + | combined = append(combined, data...) | |
| 79 | + | data = combined | |
| 80 | + | s.escapeBuf = s.escapeBuf[:0] | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | for len(data) > 0 { | |
| 84 | + | if data[0] == 0x1b { | |
| 85 | + | consumed, partial := s.consumeEscape(data) | |
| 86 | + | if consumed > 0 { | |
| 87 | + | data = data[consumed:] | |
| 88 | + | continue | |
| 89 | + | } | |
| 90 | + | if partial { | |
| 91 | + | if len(data) <= maxEscapeBufferLen { | |
| 92 | + | s.escapeBuf = append(s.escapeBuf[:0], data...) | |
| 93 | + | } | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | r, size := utf8.DecodeRune(data) | |
| 98 | + | if r == utf8.RuneError && size == 1 { | |
| 99 | + | data = data[1:] | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | s.putRune(r) | |
| 103 | + | data = data[size:] | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (s *Screen) putRune(r rune) { | |
| 108 | + | switch r { | |
| 109 | + | case '\a', 0x00, 0x0e, 0x0f, 0x7f: | |
| 110 | + | return | |
| 111 | + | case '\r': | |
| 112 | + | s.Col = 0 | |
| 113 | + | case '\n', '\v', '\f': | |
| 114 | + | if s.Frame.Width > 0 && s.Col >= s.Frame.Width { | |
| 115 | + | s.Col = s.Frame.Width - 1 | |
| 116 | + | } | |
| 117 | + | s.index() | |
| 118 | + | case '\b': | |
| 119 | + | if s.Col > 0 { | |
| 120 | + | s.Col-- | |
| 121 | + | } | |
| 122 | + | case '\t': | |
| 123 | + | spaces := 8 - (s.Col % 8) | |
| 124 | + | for range spaces { | |
| 125 | + | s.putPrintable(' ') | |
| 126 | + | } | |
| 127 | + | default: | |
| 128 | + | // Drop C1 control characters (U+0080-U+009F). | |
| 129 | + | if r >= 0x80 && r <= 0x9F { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if r >= 0x20 { | |
| 133 | + | s.putPrintable(r) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // runeWidth returns the terminal cell width of a rune. | |
| 139 | + | // | |
| 140 | + | // Returns 0 for combining marks, zero-width characters, and control codes. | |
| 141 | + | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |
| 142 | + | // Returns 1 for all other printable characters. | |
| 143 | + | // | |
| 144 | + | // This is a conservative implementation for MVP: wide/ambiguous characters | |
| 145 | + | // are reported as width 2 and rendered as a placeholder | |
| 146 | + | // so that the model cursor always stays aligned with terminal cells. | |
| 147 | + | func runeWidth(r rune) int { | |
| 148 | + | switch { | |
| 149 | + | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |
| 150 | + | return 0 | |
| 151 | + | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |
| 152 | + | case r == 0x00AD || r == 0x034F || r == 0x061C: | |
| 153 | + | return 0 | |
| 154 | + | // Hangul Jungseong O/E, Hangul Jungseong Yu | |
| 155 | + | case r == 0x115F || r == 0x1160: | |
| 156 | + | return 0 | |
| 157 | + | // Balinese musical symbols | |
| 158 | + | case r == 0x17B4 || r == 0x17B5: | |
| 159 | + | return 0 | |
| 160 | + | // Mongolian variation selectors, free variation selector | |
| 161 | + | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |
| 162 | + | return 0 | |
| 163 | + | // Line/paragraph separator, bidirectional overrides | |
| 164 | + | case r >= 0x2028 && r <= 0x202E: | |
| 165 | + | return 0 | |
| 166 | + | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |
| 167 | + | case r >= 0x2060 && r <= 0x206F: | |
| 168 | + | return 0 | |
| 169 | + | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |
| 170 | + | return 0 | |
| 171 | + | // Tags | |
| 172 | + | case r >= 0xE0001 && r <= 0xE007F: | |
| 173 | + | return 0 | |
| 174 | + | // Combining diacritical marks (Unicode categories Mn, Me) | |
| 175 | + | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |
| 176 | + | return 0 | |
| 177 | + | // --- Wide characters (width 2) --- | |
| 178 | + | // Hangul Jamo | |
| 179 | + | case r >= 0x1100 && r <= 0x115F: | |
| 180 | + | return 2 | |
| 181 | + | // Left/right-pointing angle bracket (for compatibility) | |
| 182 | + | case r == 0x2329 || r == 0x232A: | |
| 183 | + | return 2 | |
| 184 | + | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |
| 185 | + | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |
| 186 | + | // Kanbun, CJK Strokes, Enclosed CJK Letters | |
| 187 | + | case r >= 0x2E80 && r <= 0x303E: | |
| 188 | + | return 2 | |
| 189 | + | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |
| 190 | + | case r >= 0x3040 && r <= 0x33FF: | |
| 191 | + | return 2 | |
| 192 | + | // CJK Unified Ideographs Extension A | |
| 193 | + | case r >= 0x3400 && r <= 0x4DBF: | |
| 194 | + | return 2 | |
| 195 | + | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |
| 196 | + | case r >= 0x4E00 && r <= 0xA4CF: | |
| 197 | + | return 2 | |
| 198 | + | // Hangul Jamo Extended-A | |
| 199 | + | case r >= 0xA960 && r <= 0xA97C: | |
| 200 | + | return 2 | |
| 201 | + | // Hangul Syllables | |
| 202 | + | case r >= 0xAC00 && r <= 0xD7AF: | |
| 203 | + | return 2 | |
| 204 | + | // Hangul Jamo Extended-B | |
| 205 | + | case r >= 0xD7B0 && r <= 0xD7FF: | |
| 206 | + | return 2 | |
| 207 | + | // CJK Compatibility Ideographs | |
| 208 | + | case r >= 0xF900 && r <= 0xFAFF: | |
| 209 | + | return 2 | |
| 210 | + | // Vertical Forms | |
| 211 | + | case r >= 0xFE10 && r <= 0xFE19: | |
| 212 | + | return 2 | |
| 213 | + | // CJK Compatibility Forms | |
| 214 | + | case r >= 0xFE30 && r <= 0xFE6F: | |
| 215 | + | return 2 | |
| 216 | + | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |
| 217 | + | case r >= 0xFF01 && r <= 0xFF60: | |
| 218 | + | return 2 | |
| 219 | + | // Fullwidth Signs (cent, pound, yen, won, etc.) | |
| 220 | + | case r >= 0xFFE0 && r <= 0xFFE6: | |
| 221 | + | return 2 | |
| 222 | + | // Kana Supplement | |
| 223 | + | case r >= 0x1B000 && r <= 0x1B0FF: | |
| 224 | + | return 2 | |
| 225 | + | // Kana Extended-A | |
| 226 | + | case r >= 0x1B100 && r <= 0x1B12F: | |
| 227 | + | return 2 | |
| 228 | + | // Mahjong Tiles, Domino Tiles, Playing Cards | |
| 229 | + | case r >= 0x1F000 && r <= 0x1F09F: | |
| 230 | + | return 2 | |
| 231 | + | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |
| 232 | + | case r >= 0x1F0A0 && r <= 0x1F64F: | |
| 233 | + | return 2 | |
| 234 | + | // Transport and Map Symbols | |
| 235 | + | case r >= 0x1F680 && r <= 0x1F6FF: | |
| 236 | + | return 2 | |
| 237 | + | // Supplemental Symbols and Pictographs | |
| 238 | + | case r >= 0x1F900 && r <= 0x1F9FF: | |
| 239 | + | return 2 | |
| 240 | + | // CJK Unified Ideographs Extension B and beyond | |
| 241 | + | case r >= 0x20000 && r <= 0x2FFFF: | |
| 242 | + | return 2 | |
| 243 | + | // CJK Unified Ideographs Extension G and beyond | |
| 244 | + | case r >= 0x30000 && r <= 0x3FFFF: | |
| 245 | + | return 2 | |
| 246 | + | default: | |
| 247 | + | return 1 | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (s *Screen) putPrintable(r rune) { | |
| 252 | + | w := runeWidth(r) | |
| 253 | + | // Skip combining marks and zero-width characters. | |
| 254 | + | if w == 0 { | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | // Conservative normalization: treat wide/emoji as a single-cell '?' placeholder | |
| 258 | + | // so cursor and damage tracking are always consistent. The real terminal width | |
| 259 | + | // is ignored in favour of a safe single-cell replacement. | |
| 260 | + | if w > 1 { | |
| 261 | + | r = '?' | |
| 262 | + | w = 1 | |
| 263 | + | } | |
| 264 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 { | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | if s.Col >= s.Frame.Width { | |
| 268 | + | s.Col = 0 | |
| 269 | + | s.index() | |
| 270 | + | } | |
| 271 | + | if s.Row >= s.Frame.Height { | |
| 272 | + | s.Row = s.Frame.Height - 1 | |
| 273 | + | } | |
| 274 | + | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | |
| 275 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w, Height: 1, Count: 1}) | |
| 276 | + | s.Col += w | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func (s *Screen) index() { | |
| 280 | + | if s.Frame.Height == 0 { | |
| 281 | + | return | |
| 282 | + | } | |
| 283 | + | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |
| 284 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | if s.Row+1 < s.Frame.Height { | |
| 288 | + | s.Row++ | |
| 289 | + | return | |
| 290 | + | } | |
| 291 | + | s.Row = s.Frame.Height - 1 | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (s *Screen) nextLine() { | |
| 295 | + | s.Col = 0 | |
| 296 | + | s.index() | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func (s *Screen) reverseIndex() { | |
| 300 | + | if s.Frame.Height == 0 { | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |
| 304 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | if s.Row > 0 { | |
| 308 | + | s.Row-- | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |
| 313 | + | // The cursor position is preserved. | |
| 314 | + | func (s *Screen) scrollUpBy(n int) { | |
| 315 | + | if n <= 0 { | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (s *Screen) scrollDownBy(n int) { | |
| 322 | + | if n <= 0 { | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func (s *Screen) scrollUpRegion(top, bottom, n int) { | |
| 329 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 333 | + | if !ok { | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | w := s.Frame.Width | |
| 337 | + | height := bottom - top + 1 | |
| 338 | + | if n > height { | |
| 339 | + | n = height | |
| 340 | + | } | |
| 341 | + | for y := top; y <= bottom-n; y++ { | |
| 342 | + | dst := y * w | |
| 343 | + | src := (y + n) * w | |
| 344 | + | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |
| 345 | + | } | |
| 346 | + | for y := bottom - n + 1; y <= bottom; y++ { | |
| 347 | + | start := y * w | |
| 348 | + | for i := start; i < start+w; i++ { | |
| 349 | + | s.Frame.Cells[i] = renderer.BlankCell() | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 353 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |
| 357 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 358 | + | return | |
| 359 | + | } | |
| 360 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 361 | + | if !ok { | |
| 362 | + | return | |
| 363 | + | } | |
| 364 | + | w := s.Frame.Width | |
| 365 | + | height := bottom - top + 1 | |
| 366 | + | if n > height { | |
| 367 | + | n = height | |
| 368 | + | } | |
| 369 | + | for y := bottom; y >= top+n; y-- { | |
| 370 | + | dst := y * w | |
| 371 | + | src := (y - n) * w | |
| 372 | + | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |
| 373 | + | } | |
| 374 | + | for y := top; y < top+n; y++ { | |
| 375 | + | start := y * w | |
| 376 | + | for i := start; i < start+w; i++ { | |
| 377 | + | s.Frame.Cells[i] = renderer.BlankCell() | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |
| 384 | + | if s.Frame.Height == 0 || top > bottom { | |
| 385 | + | return 0, 0, false | |
| 386 | + | } | |
| 387 | + | top = clamp(top, 0, s.Frame.Height-1) | |
| 388 | + | bottom = clamp(bottom, 0, s.Frame.Height-1) | |
| 389 | + | return top, bottom, top <= bottom | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (s *Screen) record(d renderer.Damage) { | |
| 393 | + | // Replace FullRedraw with the first concrete damage item. | |
| 394 | + | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |
| 395 | + | s.damage[0] = d | |
| 396 | + | return | |
| 397 | + | } | |
| 398 | + | // Coalesce adjacent single-cell text damage on the same line. | |
| 399 | + | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |
| 400 | + | last := &s.damage[len(s.damage)-1] | |
| 401 | + | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |
| 402 | + | last.Width++ | |
| 403 | + | return | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | s.damage = append(s.damage, d) | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (s *Screen) fullRedraw() { | |
| 410 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |
| 414 | + | if len(data) < 2 { | |
| 415 | + | return 0, true | |
| 416 | + | } | |
| 417 | + | switch data[1] { | |
| 418 | + | case ']': | |
| 419 | + | return consumeOSC(data) | |
| 420 | + | case 'P': | |
| 421 | + | return consumeSTString(data) | |
| 422 | + | case '_', '^', 'X': | |
| 423 | + | return consumeSTString(data) | |
| 424 | + | case '[': | |
| 425 | + | return s.consumeCSI(data) | |
| 426 | + | case '=': | |
| 427 | + | return 2, false | |
| 428 | + | case '>': | |
| 429 | + | return 2, false | |
| 430 | + | case '7': | |
| 431 | + | s.saveCursor() | |
| 432 | + | return 2, false | |
| 433 | + | case '8': | |
| 434 | + | s.restoreCursor() | |
| 435 | + | return 2, false | |
| 436 | + | case 'D': | |
| 437 | + | s.index() | |
| 438 | + | return 2, false | |
| 439 | + | case 'E': | |
| 440 | + | s.nextLine() | |
| 441 | + | return 2, false | |
| 442 | + | case 'M': | |
| 443 | + | s.reverseIndex() | |
| 444 | + | return 2, false | |
| 445 | + | case 'c': | |
| 446 | + | s.reset() | |
| 447 | + | return 2, false | |
| 448 | + | case 'H': | |
| 449 | + | return 2, false | |
| 450 | + | case '(', ')', '*', '+', '-', '.', '/': | |
| 451 | + | if len(data) < 3 { | |
| 452 | + | return 0, true | |
| 453 | + | } | |
| 454 | + | return 3, false | |
| 455 | + | default: | |
| 456 | + | if data[1] >= 0x30 && data[1] <= 0x7e { | |
| 457 | + | return 2, false | |
| 458 | + | } | |
| 459 | + | return 0, false | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) { | |
| 464 | + | end := -1 | |
| 465 | + | for i := 2; i < len(data); i++ { | |
| 466 | + | if data[i] >= '@' && data[i] <= '~' { | |
| 467 | + | end = i | |
| 468 | + | break | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | if end == -1 { | |
| 472 | + | return 0, true | |
| 473 | + | } | |
| 474 | + | params := string(data[2:end]) | |
| 475 | + | cmd := data[end] | |
| 476 | + | s.applyCSI(params, cmd) | |
| 477 | + | return end + 1, false | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func consumeOSC(data []byte) (consumed int, partial bool) { | |
| 481 | + | for i := 2; i < len(data); i++ { | |
| 482 | + | switch data[i] { | |
| 483 | + | case 0x07: | |
| 484 | + | return i + 1, false | |
| 485 | + | case 0x1b: | |
| 486 | + | if i+1 < len(data) && data[i+1] == '\\' { | |
| 487 | + | return i + 2, false | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | return 0, true | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | func consumeSTString(data []byte) (consumed int, partial bool) { | |
| 495 | + | for i := 2; i < len(data); i++ { | |
| 496 | + | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| 497 | + | return i + 2, false | |
| 498 | + | } | |
| 499 | + | } | |
| 500 | + | return 0, true | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func (s *Screen) applyCSI(params string, cmd byte) { | |
| 504 | + | private := strings.HasPrefix(params, "?") | |
| 505 | + | parts := parseCSIInts(params) | |
| 506 | + | switch cmd { | |
| 507 | + | case 'm': | |
| 508 | + | if strings.HasPrefix(params, ">") { | |
| 509 | + | return | |
| 510 | + | } | |
| 511 | + | s.applySGR(params) | |
| 512 | + | case 'J': | |
| 513 | + | mode := 0 | |
| 514 | + | if len(parts) > 0 { | |
| 515 | + | mode = parts[0] | |
| 516 | + | } | |
| 517 | + | s.clearScreenMode(mode) | |
| 518 | + | case 'K': | |
| 519 | + | mode := 0 | |
| 520 | + | if len(parts) > 0 { | |
| 521 | + | mode = parts[0] | |
| 522 | + | } | |
| 523 | + | s.clearLineMode(mode) | |
| 524 | + | case 'S': | |
| 525 | + | s.scrollUpBy(firstPositive(parts, 1)) | |
| 526 | + | case 'T': | |
| 527 | + | s.scrollDownBy(firstPositive(parts, 1)) | |
| 528 | + | case 'H', 'f': | |
| 529 | + | row, col := 1, 1 | |
| 530 | + | if len(parts) > 0 && parts[0] > 0 { | |
| 531 | + | row = parts[0] | |
| 532 | + | } | |
| 533 | + | if len(parts) > 1 && parts[1] > 0 { | |
| 534 | + | col = parts[1] | |
| 535 | + | } | |
| 536 | + | s.Row = clamp(row-1, 0, s.Frame.Height-1) | |
| 537 | + | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |
| 538 | + | case 'A': | |
| 539 | + | s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1) | |
| 540 | + | case 'B': | |
| 541 | + | s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1) | |
| 542 | + | case 'C': | |
| 543 | + | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1) | |
| 544 | + | case 'D': | |
| 545 | + | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1) | |
| 546 | + | case 'E': | |
| 547 | + | s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1) | |
| 548 | + | s.Col = 0 | |
| 549 | + | case 'F': | |
| 550 | + | s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1) | |
| 551 | + | s.Col = 0 | |
| 552 | + | case 'G': | |
| 553 | + | col := firstPositive(parts, 1) | |
| 554 | + | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |
| 555 | + | case 'd': | |
| 556 | + | row := firstPositive(parts, 1) | |
| 557 | + | s.Row = clamp(row-1, 0, s.Frame.Height-1) | |
| 558 | + | case '@': | |
| 559 | + | s.insertChars(firstPositive(parts, 1)) | |
| 560 | + | case 'P': | |
| 561 | + | s.deleteChars(firstPositive(parts, 1)) | |
| 562 | + | case 'L': | |
| 563 | + | s.insertLines(firstPositive(parts, 1)) | |
| 564 | + | case 'M': | |
| 565 | + | s.deleteLines(firstPositive(parts, 1)) | |
| 566 | + | case 'r': | |
| 567 | + | s.setScrollRegion(parts) | |
| 568 | + | case 's': | |
| 569 | + | s.saveCursor() | |
| 570 | + | case 'u': | |
| 571 | + | s.restoreCursor() | |
| 572 | + | case 'h': | |
| 573 | + | s.setMode(private, parts, true) | |
| 574 | + | case 'l': | |
| 575 | + | s.setMode(private, parts, false) | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | func firstPositive(parts []int, fallback int) int { | |
| 580 | + | if len(parts) == 0 || parts[0] <= 0 { | |
| 581 | + | return fallback | |
| 582 | + | } | |
| 583 | + | return parts[0] | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | func (s *Screen) applySGR(params string) { | |
| 587 | + | if params == "" { | |
| 588 | + | s.Style = renderer.DefaultStyle() | |
| 589 | + | return | |
| 590 | + | } | |
| 591 | + | parts := parseCSIInts(params) | |
| 592 | + | for i := 0; i < len(parts); i++ { | |
| 593 | + | switch parts[i] { | |
| 594 | + | case 0: | |
| 595 | + | s.Style = renderer.DefaultStyle() | |
| 596 | + | case 1: | |
| 597 | + | s.Style.Bold = true | |
| 598 | + | case 7: | |
| 599 | + | s.Style.Inverse = true | |
| 600 | + | case 22: | |
| 601 | + | s.Style.Bold = false | |
| 602 | + | case 27: | |
| 603 | + | s.Style.Inverse = false | |
| 604 | + | case 30, 31, 32, 33, 34, 35, 36, 37: | |
| 605 | + | s.Style.Foreground = parts[i] - 30 | |
| 606 | + | s.Style.HasForegroundRGB = false | |
| 607 | + | case 39: | |
| 608 | + | s.Style.Foreground = -1 | |
| 609 | + | s.Style.HasForegroundRGB = false | |
| 610 | + | case 40, 41, 42, 43, 44, 45, 46, 47: | |
| 611 | + | s.Style.Background = parts[i] - 40 | |
| 612 | + | s.Style.HasBackgroundRGB = false | |
| 613 | + | case 49: | |
| 614 | + | s.Style.Background = -1 | |
| 615 | + | s.Style.HasBackgroundRGB = false | |
| 616 | + | case 90, 91, 92, 93, 94, 95, 96, 97: | |
| 617 | + | s.Style.Foreground = parts[i] - 90 + 8 | |
| 618 | + | s.Style.HasForegroundRGB = false | |
| 619 | + | case 100, 101, 102, 103, 104, 105, 106, 107: | |
| 620 | + | s.Style.Background = parts[i] - 100 + 8 | |
| 621 | + | s.Style.HasBackgroundRGB = false | |
| 622 | + | case 38: | |
| 623 | + | if i+2 < len(parts) && parts[i+1] == 5 { | |
| 624 | + | s.Style.Foreground = parts[i+2] | |
| 625 | + | s.Style.HasForegroundRGB = false | |
| 626 | + | i += 2 | |
| 627 | + | } else if i+4 < len(parts) && parts[i+1] == 2 { | |
| 628 | + | s.Style.Foreground = -1 | |
| 629 | + | s.Style.HasForegroundRGB = true | |
| 630 | + | s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |
| 631 | + | i += 4 | |
| 632 | + | } | |
| 633 | + | case 48: | |
| 634 | + | if i+2 < len(parts) && parts[i+1] == 5 { | |
| 635 | + | s.Style.Background = parts[i+2] | |
| 636 | + | s.Style.HasBackgroundRGB = false | |
| 637 | + | i += 2 | |
| 638 | + | } else if i+4 < len(parts) && parts[i+1] == 2 { | |
| 639 | + | s.Style.Background = -1 | |
| 640 | + | s.Style.HasBackgroundRGB = true | |
| 641 | + | s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |
| 642 | + | i += 4 | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | func sgrRGB(r, g, b int) renderer.RGB { | |
| 649 | + | return renderer.RGB{ | |
| 650 | + | R: uint8(clamp(r, 0, 255)), | |
| 651 | + | G: uint8(clamp(g, 0, 255)), | |
| 652 | + | B: uint8(clamp(b, 0, 255)), | |
| 653 | + | } | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | func (s *Screen) saveCursor() { | |
| 657 | + | s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, saved: true} | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | func (s *Screen) restoreCursor() { | |
| 661 | + | if !s.savedCursor.saved { | |
| 662 | + | return | |
| 663 | + | } | |
| 664 | + | s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1) | |
| 665 | + | s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1) | |
| 666 | + | s.Style = s.savedCursor.style | |
| 667 | + | } | |
| 668 | + | ||
| 669 | + | func (s *Screen) reset() { | |
| 670 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| 671 | + | s.Row, s.Col = 0, 0 | |
| 672 | + | s.Style = renderer.DefaultStyle() | |
| 673 | + | s.escapeBuf = s.escapeBuf[:0] | |
| 674 | + | s.savedCursor = cursorState{} | |
| 675 | + | s.alternate = nil | |
| 676 | + | s.resetScrollRegion() | |
| 677 | + | s.fullRedraw() | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func (s *Screen) setScrollRegion(parts []int) { | |
| 681 | + | if s.Frame.Height == 0 { | |
| 682 | + | return | |
| 683 | + | } | |
| 684 | + | top, bottom := 1, s.Frame.Height | |
| 685 | + | if len(parts) > 0 && parts[0] > 0 { | |
| 686 | + | top = parts[0] | |
| 687 | + | } | |
| 688 | + | if len(parts) > 1 && parts[1] > 0 { | |
| 689 | + | bottom = parts[1] | |
| 690 | + | } | |
| 691 | + | if top >= bottom { | |
| 692 | + | s.resetScrollRegion() | |
| 693 | + | } else { | |
| 694 | + | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |
| 695 | + | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |
| 696 | + | if s.scrollTop >= s.scrollBottom { | |
| 697 | + | s.resetScrollRegion() | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | s.Row, s.Col = 0, 0 | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | func (s *Screen) resetScrollRegion() { | |
| 704 | + | s.scrollTop = 0 | |
| 705 | + | if s.Frame.Height > 0 { | |
| 706 | + | s.scrollBottom = s.Frame.Height - 1 | |
| 707 | + | } else { | |
| 708 | + | s.scrollBottom = 0 | |
| 709 | + | } | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |
| 713 | + | if !private { | |
| 714 | + | return | |
| 715 | + | } | |
| 716 | + | for _, mode := range parts { | |
| 717 | + | switch mode { | |
| 718 | + | case 47, 1047, 1049: | |
| 719 | + | if enabled { | |
| 720 | + | s.enterAlternateScreen() | |
| 721 | + | } else { | |
| 722 | + | s.exitAlternateScreen() | |
| 723 | + | } | |
| 724 | + | case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 2004, 2026: | |
| 725 | + | // Trackable terminal modes that do not directly affect the current | |
| 726 | + | // cell model yet. Consuming them prevents mode bytes from leaking. | |
| 727 | + | continue | |
| 728 | + | } | |
| 729 | + | } | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | func (s *Screen) enterAlternateScreen() { | |
| 733 | + | if s.alternate == nil { | |
| 734 | + | s.alternate = &screenState{ | |
| 735 | + | frame: cloneFrame(s.Frame), | |
| 736 | + | row: s.Row, | |
| 737 | + | col: s.Col, | |
| 738 | + | style: s.Style, | |
| 739 | + | scrollTop: s.scrollTop, | |
| 740 | + | scrollBottom: s.scrollBottom, | |
| 741 | + | savedCursor: s.savedCursor, | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |
| 745 | + | s.Row, s.Col = 0, 0 | |
| 746 | + | s.Style = renderer.DefaultStyle() | |
| 747 | + | s.savedCursor = cursorState{} | |
| 748 | + | s.resetScrollRegion() | |
| 749 | + | s.fullRedraw() | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | func (s *Screen) exitAlternateScreen() { | |
| 753 | + | if s.alternate == nil { | |
| 754 | + | return | |
| 755 | + | } | |
| 756 | + | state := s.alternate | |
| 757 | + | s.Frame = cloneFrame(state.frame) | |
| 758 | + | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |
| 759 | + | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |
| 760 | + | s.Style = state.style | |
| 761 | + | s.scrollTop = state.scrollTop | |
| 762 | + | s.scrollBottom = state.scrollBottom | |
| 763 | + | s.savedCursor = state.savedCursor | |
| 764 | + | s.alternate = nil | |
| 765 | + | s.fullRedraw() | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | func cloneFrame(frame renderer.Frame) renderer.Frame { | |
| 769 | + | out := renderer.NewFrame(frame.Width, frame.Height) | |
| 770 | + | copy(out.Cells, frame.Cells) | |
| 771 | + | return out | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | func (s *Screen) insertChars(n int) { | |
| 775 | + | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | |
| 776 | + | return | |
| 777 | + | } | |
| 778 | + | w := s.Frame.Width | |
| 779 | + | if n > w-s.Col { | |
| 780 | + | n = w - s.Col | |
| 781 | + | } | |
| 782 | + | rowStart := s.Row * w | |
| 783 | + | for x := w - 1; x >= s.Col+n; x-- { | |
| 784 | + | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n] | |
| 785 | + | } | |
| 786 | + | for x := s.Col; x < s.Col+n; x++ { | |
| 787 | + | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | |
| 788 | + | } | |
| 789 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1}) | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | func (s *Screen) deleteChars(n int) { | |
| 793 | + | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | |
| 794 | + | return | |
| 795 | + | } | |
| 796 | + | w := s.Frame.Width | |
| 797 | + | if n > w-s.Col { | |
| 798 | + | n = w - s.Col | |
| 799 | + | } | |
| 800 | + | rowStart := s.Row * w | |
| 801 | + | for x := s.Col; x < w-n; x++ { | |
| 802 | + | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n] | |
| 803 | + | } | |
| 804 | + | for x := w - n; x < w; x++ { | |
| 805 | + | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | |
| 806 | + | } | |
| 807 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1}) | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | func (s *Screen) insertLines(n int) { | |
| 811 | + | if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 { | |
| 812 | + | return | |
| 813 | + | } | |
| 814 | + | s.scrollDownRegion(s.Row, s.scrollBottom, n) | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | func (s *Screen) deleteLines(n int) { | |
| 818 | + | if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 { | |
| 819 | + | return | |
| 820 | + | } | |
| 821 | + | s.scrollUpRegion(s.Row, s.scrollBottom, n) | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | func (s *Screen) clearScreenMode(mode int) { | |
| 825 | + | s.clampCursor() | |
| 826 | + | switch mode { | |
| 827 | + | case 1: | |
| 828 | + | for y := 0; y <= s.Row && y < s.Frame.Height; y++ { | |
| 829 | + | end := s.Frame.Width | |
| 830 | + | if y == s.Row { | |
| 831 | + | end = min(s.Col+1, s.Frame.Width) | |
| 832 | + | } | |
| 833 | + | for x := 0; x < end; x++ { | |
| 834 | + | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 835 | + | } | |
| 836 | + | } | |
| 837 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1}) | |
| 838 | + | case 2, 3: | |
| 839 | + | for i := range s.Frame.Cells { | |
| 840 | + | s.Frame.Cells[i] = renderer.BlankCell() | |
| 841 | + | } | |
| 842 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |
| 843 | + | default: | |
| 844 | + | for y := s.Row; y < s.Frame.Height; y++ { | |
| 845 | + | start := 0 | |
| 846 | + | if y == s.Row { | |
| 847 | + | start = s.Col | |
| 848 | + | } | |
| 849 | + | for x := start; x < s.Frame.Width; x++ { | |
| 850 | + | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 851 | + | } | |
| 852 | + | } | |
| 853 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1}) | |
| 854 | + | } | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | func (s *Screen) clearLineMode(mode int) { | |
| 858 | + | s.clampCursor() | |
| 859 | + | switch mode { | |
| 860 | + | case 1: | |
| 861 | + | end := min(s.Col+1, s.Frame.Width) | |
| 862 | + | for x := 0; x < end; x++ { | |
| 863 | + | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |
| 864 | + | } | |
| 865 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: end, Height: 1, Count: 1}) | |
| 866 | + | case 2: | |
| 867 | + | for x := 0; x < s.Frame.Width; x++ { | |
| 868 | + | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |
| 869 | + | } | |
| 870 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: 1, Count: 1}) | |
| 871 | + | default: | |
| 872 | + | for x := s.Col; x < s.Frame.Width; x++ { | |
| 873 | + | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |
| 874 | + | } | |
| 875 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: s.Col, Y: s.Row, Width: s.Frame.Width - s.Col, Height: 1, Count: 1}) | |
| 876 | + | } | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | func (s *Screen) clampCursor() { | |
| 880 | + | s.Row = clamp(s.Row, 0, s.Frame.Height-1) | |
| 881 | + | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | func parseCSIInts(params string) []int { | |
| 885 | + | if params == "" { | |
| 886 | + | return nil | |
| 887 | + | } | |
| 888 | + | params = strings.TrimPrefix(params, "?") | |
| 889 | + | params = strings.TrimPrefix(params, ">") | |
| 890 | + | parts := strings.Split(params, ";") | |
| 891 | + | out := make([]int, 0, len(parts)) | |
| 892 | + | for _, p := range parts { | |
| 893 | + | if p == "" { | |
| 894 | + | out = append(out, 0) | |
| 895 | + | continue | |
| 896 | + | } | |
| 897 | + | v, err := strconv.Atoi(p) | |
| 898 | + | if err != nil { | |
| 899 | + | out = append(out, 0) | |
| 900 | + | continue | |
| 901 | + | } | |
| 902 | + | out = append(out, v) | |
| 903 | + | } | |
| 904 | + | return out | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | func clamp(v, lo, hi int) int { | |
| 908 | + | if hi < lo { | |
| 909 | + | return lo | |
| 910 | + | } | |
| 911 | + | if v < lo { | |
| 912 | + | return lo | |
| 913 | + | } | |
| 914 | + | if v > hi { | |
| 915 | + | return hi | |
| 916 | + | } | |
| 917 | + | return v | |
| 918 | + | } | |
| @@ -0,0 +1,918 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strconv" | |||
| 5 | + | "strings" | |||
| 6 | + | "unicode" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type cursorState struct { | |||
| 13 | + | row int | |||
| 14 | + | col int | |||
| 15 | + | style renderer.Style | |||
| 16 | + | saved bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type screenState struct { | |||
| 20 | + | frame renderer.Frame | |||
| 21 | + | row int | |||
| 22 | + | col int | |||
| 23 | + | style renderer.Style | |||
| 24 | + | scrollTop int | |||
| 25 | + | scrollBottom int | |||
| 26 | + | savedCursor cursorState | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | const maxEscapeBufferLen = 4096 | |||
| 30 | + | ||||
| 31 | + | type Screen struct { | |||
| 32 | + | Frame renderer.Frame | |||
| 33 | + | Row int | |||
| 34 | + | Col int | |||
| 35 | + | Style renderer.Style | |||
| 36 | + | damage []renderer.Damage | |||
| 37 | + | escapeBuf []byte | |||
| 38 | + | ||||
| 39 | + | scrollTop int | |||
| 40 | + | scrollBottom int | |||
| 41 | + | savedCursor cursorState | |||
| 42 | + | alternate *screenState | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func NewScreen(width, height int) *Screen { | |||
| 46 | + | s := &Screen{ | |||
| 47 | + | Frame: renderer.NewFrame(width, height), | |||
| 48 | + | Style: renderer.DefaultStyle(), | |||
| 49 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 50 | + | } | |||
| 51 | + | s.resetScrollRegion() | |||
| 52 | + | return s | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (s *Screen) Resize(width, height int) { | |||
| 56 | + | if width == s.Frame.Width && height == s.Frame.Height { | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | s.Frame = renderer.NewFrame(width, height) | |||
| 60 | + | s.Row, s.Col = 0, 0 | |||
| 61 | + | s.Style = renderer.DefaultStyle() | |||
| 62 | + | s.escapeBuf = s.escapeBuf[:0] | |||
| 63 | + | s.savedCursor = cursorState{} | |||
| 64 | + | s.alternate = nil | |||
| 65 | + | s.resetScrollRegion() | |||
| 66 | + | s.fullRedraw() | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Damage returns the current damage list. The caller must not modify the | |||
| 70 | + | // returned slice; ClearDamage must be called after the damage is consumed. | |||
| 71 | + | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||
| 72 | + | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | |||
| 73 | + | ||||
| 74 | + | func (s *Screen) Write(data []byte) { | |||
| 75 | + | if len(s.escapeBuf) > 0 { | |||
| 76 | + | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |||
| 77 | + | combined = append(combined, s.escapeBuf...) | |||
| 78 | + | combined = append(combined, data...) | |||
| 79 | + | data = combined | |||
| 80 | + | s.escapeBuf = s.escapeBuf[:0] | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | for len(data) > 0 { | |||
| 84 | + | if data[0] == 0x1b { | |||
| 85 | + | consumed, partial := s.consumeEscape(data) | |||
| 86 | + | if consumed > 0 { | |||
| 87 | + | data = data[consumed:] | |||
| 88 | + | continue | |||
| 89 | + | } | |||
| 90 | + | if partial { | |||
| 91 | + | if len(data) <= maxEscapeBufferLen { | |||
| 92 | + | s.escapeBuf = append(s.escapeBuf[:0], data...) | |||
| 93 | + | } | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | r, size := utf8.DecodeRune(data) | |||
| 98 | + | if r == utf8.RuneError && size == 1 { | |||
| 99 | + | data = data[1:] | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | s.putRune(r) | |||
| 103 | + | data = data[size:] | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (s *Screen) putRune(r rune) { | |||
| 108 | + | switch r { | |||
| 109 | + | case '\a', 0x00, 0x0e, 0x0f, 0x7f: | |||
| 110 | + | return | |||
| 111 | + | case '\r': | |||
| 112 | + | s.Col = 0 | |||
| 113 | + | case '\n', '\v', '\f': | |||
| 114 | + | if s.Frame.Width > 0 && s.Col >= s.Frame.Width { | |||
| 115 | + | s.Col = s.Frame.Width - 1 | |||
| 116 | + | } | |||
| 117 | + | s.index() | |||
| 118 | + | case '\b': | |||
| 119 | + | if s.Col > 0 { | |||
| 120 | + | s.Col-- | |||
| 121 | + | } | |||
| 122 | + | case '\t': | |||
| 123 | + | spaces := 8 - (s.Col % 8) | |||
| 124 | + | for range spaces { | |||
| 125 | + | s.putPrintable(' ') | |||
| 126 | + | } | |||
| 127 | + | default: | |||
| 128 | + | // Drop C1 control characters (U+0080-U+009F). | |||
| 129 | + | if r >= 0x80 && r <= 0x9F { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | if r >= 0x20 { | |||
| 133 | + | s.putPrintable(r) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // runeWidth returns the terminal cell width of a rune. | |||
| 139 | + | // | |||
| 140 | + | // Returns 0 for combining marks, zero-width characters, and control codes. | |||
| 141 | + | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |||
| 142 | + | // Returns 1 for all other printable characters. | |||
| 143 | + | // | |||
| 144 | + | // This is a conservative implementation for MVP: wide/ambiguous characters | |||
| 145 | + | // are reported as width 2 and rendered as a placeholder | |||
| 146 | + | // so that the model cursor always stays aligned with terminal cells. | |||
| 147 | + | func runeWidth(r rune) int { | |||
| 148 | + | switch { | |||
| 149 | + | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |||
| 150 | + | return 0 | |||
| 151 | + | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |||
| 152 | + | case r == 0x00AD || r == 0x034F || r == 0x061C: | |||
| 153 | + | return 0 | |||
| 154 | + | // Hangul Jungseong O/E, Hangul Jungseong Yu | |||
| 155 | + | case r == 0x115F || r == 0x1160: | |||
| 156 | + | return 0 | |||
| 157 | + | // Balinese musical symbols | |||
| 158 | + | case r == 0x17B4 || r == 0x17B5: | |||
| 159 | + | return 0 | |||
| 160 | + | // Mongolian variation selectors, free variation selector | |||
| 161 | + | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |||
| 162 | + | return 0 | |||
| 163 | + | // Line/paragraph separator, bidirectional overrides | |||
| 164 | + | case r >= 0x2028 && r <= 0x202E: | |||
| 165 | + | return 0 | |||
| 166 | + | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |||
| 167 | + | case r >= 0x2060 && r <= 0x206F: | |||
| 168 | + | return 0 | |||
| 169 | + | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |||
| 170 | + | return 0 | |||
| 171 | + | // Tags | |||
| 172 | + | case r >= 0xE0001 && r <= 0xE007F: | |||
| 173 | + | return 0 | |||
| 174 | + | // Combining diacritical marks (Unicode categories Mn, Me) | |||
| 175 | + | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |||
| 176 | + | return 0 | |||
| 177 | + | // --- Wide characters (width 2) --- | |||
| 178 | + | // Hangul Jamo | |||
| 179 | + | case r >= 0x1100 && r <= 0x115F: | |||
| 180 | + | return 2 | |||
| 181 | + | // Left/right-pointing angle bracket (for compatibility) | |||
| 182 | + | case r == 0x2329 || r == 0x232A: | |||
| 183 | + | return 2 | |||
| 184 | + | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |||
| 185 | + | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |||
| 186 | + | // Kanbun, CJK Strokes, Enclosed CJK Letters | |||
| 187 | + | case r >= 0x2E80 && r <= 0x303E: | |||
| 188 | + | return 2 | |||
| 189 | + | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |||
| 190 | + | case r >= 0x3040 && r <= 0x33FF: | |||
| 191 | + | return 2 | |||
| 192 | + | // CJK Unified Ideographs Extension A | |||
| 193 | + | case r >= 0x3400 && r <= 0x4DBF: | |||
| 194 | + | return 2 | |||
| 195 | + | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |||
| 196 | + | case r >= 0x4E00 && r <= 0xA4CF: | |||
| 197 | + | return 2 | |||
| 198 | + | // Hangul Jamo Extended-A | |||
| 199 | + | case r >= 0xA960 && r <= 0xA97C: | |||
| 200 | + | return 2 | |||
| 201 | + | // Hangul Syllables | |||
| 202 | + | case r >= 0xAC00 && r <= 0xD7AF: | |||
| 203 | + | return 2 | |||
| 204 | + | // Hangul Jamo Extended-B | |||
| 205 | + | case r >= 0xD7B0 && r <= 0xD7FF: | |||
| 206 | + | return 2 | |||
| 207 | + | // CJK Compatibility Ideographs | |||
| 208 | + | case r >= 0xF900 && r <= 0xFAFF: | |||
| 209 | + | return 2 | |||
| 210 | + | // Vertical Forms | |||
| 211 | + | case r >= 0xFE10 && r <= 0xFE19: | |||
| 212 | + | return 2 | |||
| 213 | + | // CJK Compatibility Forms | |||
| 214 | + | case r >= 0xFE30 && r <= 0xFE6F: | |||
| 215 | + | return 2 | |||
| 216 | + | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |||
| 217 | + | case r >= 0xFF01 && r <= 0xFF60: | |||
| 218 | + | return 2 | |||
| 219 | + | // Fullwidth Signs (cent, pound, yen, won, etc.) | |||
| 220 | + | case r >= 0xFFE0 && r <= 0xFFE6: | |||
| 221 | + | return 2 | |||
| 222 | + | // Kana Supplement | |||
| 223 | + | case r >= 0x1B000 && r <= 0x1B0FF: | |||
| 224 | + | return 2 | |||
| 225 | + | // Kana Extended-A | |||
| 226 | + | case r >= 0x1B100 && r <= 0x1B12F: | |||
| 227 | + | return 2 | |||
| 228 | + | // Mahjong Tiles, Domino Tiles, Playing Cards | |||
| 229 | + | case r >= 0x1F000 && r <= 0x1F09F: | |||
| 230 | + | return 2 | |||
| 231 | + | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |||
| 232 | + | case r >= 0x1F0A0 && r <= 0x1F64F: | |||
| 233 | + | return 2 | |||
| 234 | + | // Transport and Map Symbols | |||
| 235 | + | case r >= 0x1F680 && r <= 0x1F6FF: | |||
| 236 | + | return 2 | |||
| 237 | + | // Supplemental Symbols and Pictographs | |||
| 238 | + | case r >= 0x1F900 && r <= 0x1F9FF: | |||
| 239 | + | return 2 | |||
| 240 | + | // CJK Unified Ideographs Extension B and beyond | |||
| 241 | + | case r >= 0x20000 && r <= 0x2FFFF: | |||
| 242 | + | return 2 | |||
| 243 | + | // CJK Unified Ideographs Extension G and beyond | |||
| 244 | + | case r >= 0x30000 && r <= 0x3FFFF: | |||
| 245 | + | return 2 | |||
| 246 | + | default: | |||
| 247 | + | return 1 | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (s *Screen) putPrintable(r rune) { | |||
| 252 | + | w := runeWidth(r) | |||
| 253 | + | // Skip combining marks and zero-width characters. | |||
| 254 | + | if w == 0 { | |||
| 255 | + | return | |||
| 256 | + | } | |||
| 257 | + | // Conservative normalization: treat wide/emoji as a single-cell '?' placeholder | |||
| 258 | + | // so cursor and damage tracking are always consistent. The real terminal width | |||
| 259 | + | // is ignored in favour of a safe single-cell replacement. | |||
| 260 | + | if w > 1 { | |||
| 261 | + | r = '?' | |||
| 262 | + | w = 1 | |||
| 263 | + | } | |||
| 264 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 { | |||
| 265 | + | return | |||
| 266 | + | } | |||
| 267 | + | if s.Col >= s.Frame.Width { | |||
| 268 | + | s.Col = 0 | |||
| 269 | + | s.index() | |||
| 270 | + | } | |||
| 271 | + | if s.Row >= s.Frame.Height { | |||
| 272 | + | s.Row = s.Frame.Height - 1 | |||
| 273 | + | } | |||
| 274 | + | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | |||
| 275 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w, Height: 1, Count: 1}) | |||
| 276 | + | s.Col += w | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func (s *Screen) index() { | |||
| 280 | + | if s.Frame.Height == 0 { | |||
| 281 | + | return | |||
| 282 | + | } | |||
| 283 | + | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |||
| 284 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 285 | + | return | |||
| 286 | + | } | |||
| 287 | + | if s.Row+1 < s.Frame.Height { | |||
| 288 | + | s.Row++ | |||
| 289 | + | return | |||
| 290 | + | } | |||
| 291 | + | s.Row = s.Frame.Height - 1 | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (s *Screen) nextLine() { | |||
| 295 | + | s.Col = 0 | |||
| 296 | + | s.index() | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func (s *Screen) reverseIndex() { | |||
| 300 | + | if s.Frame.Height == 0 { | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |||
| 304 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | if s.Row > 0 { | |||
| 308 | + | s.Row-- | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |||
| 313 | + | // The cursor position is preserved. | |||
| 314 | + | func (s *Screen) scrollUpBy(n int) { | |||
| 315 | + | if n <= 0 { | |||
| 316 | + | return | |||
| 317 | + | } | |||
| 318 | + | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func (s *Screen) scrollDownBy(n int) { | |||
| 322 | + | if n <= 0 { | |||
| 323 | + | return | |||
| 324 | + | } | |||
| 325 | + | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 329 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 332 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 333 | + | if !ok { | |||
| 334 | + | return | |||
| 335 | + | } | |||
| 336 | + | w := s.Frame.Width | |||
| 337 | + | height := bottom - top + 1 | |||
| 338 | + | if n > height { | |||
| 339 | + | n = height | |||
| 340 | + | } | |||
| 341 | + | for y := top; y <= bottom-n; y++ { | |||
| 342 | + | dst := y * w | |||
| 343 | + | src := (y + n) * w | |||
| 344 | + | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |||
| 345 | + | } | |||
| 346 | + | for y := bottom - n + 1; y <= bottom; y++ { | |||
| 347 | + | start := y * w | |||
| 348 | + | for i := start; i < start+w; i++ { | |||
| 349 | + | s.Frame.Cells[i] = renderer.BlankCell() | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |||
| 353 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 357 | + | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 358 | + | return | |||
| 359 | + | } | |||
| 360 | + | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 361 | + | if !ok { | |||
| 362 | + | return | |||
| 363 | + | } | |||
| 364 | + | w := s.Frame.Width | |||
| 365 | + | height := bottom - top + 1 | |||
| 366 | + | if n > height { | |||
| 367 | + | n = height | |||
| 368 | + | } | |||
| 369 | + | for y := bottom; y >= top+n; y-- { | |||
| 370 | + | dst := y * w | |||
| 371 | + | src := (y - n) * w | |||
| 372 | + | copy(s.Frame.Cells[dst:dst+w], s.Frame.Cells[src:src+w]) | |||
| 373 | + | } | |||
| 374 | + | for y := top; y < top+n; y++ { | |||
| 375 | + | start := y * w | |||
| 376 | + | for i := start; i < start+w; i++ { | |||
| 377 | + | s.Frame.Cells[i] = renderer.BlankCell() | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 384 | + | if s.Frame.Height == 0 || top > bottom { | |||
| 385 | + | return 0, 0, false | |||
| 386 | + | } | |||
| 387 | + | top = clamp(top, 0, s.Frame.Height-1) | |||
| 388 | + | bottom = clamp(bottom, 0, s.Frame.Height-1) | |||
| 389 | + | return top, bottom, top <= bottom | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func (s *Screen) record(d renderer.Damage) { | |||
| 393 | + | // Replace FullRedraw with the first concrete damage item. | |||
| 394 | + | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |||
| 395 | + | s.damage[0] = d | |||
| 396 | + | return | |||
| 397 | + | } | |||
| 398 | + | // Coalesce adjacent single-cell text damage on the same line. | |||
| 399 | + | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |||
| 400 | + | last := &s.damage[len(s.damage)-1] | |||
| 401 | + | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |||
| 402 | + | last.Width++ | |||
| 403 | + | return | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | s.damage = append(s.damage, d) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func (s *Screen) fullRedraw() { | |||
| 410 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||
| 414 | + | if len(data) < 2 { | |||
| 415 | + | return 0, true | |||
| 416 | + | } | |||
| 417 | + | switch data[1] { | |||
| 418 | + | case ']': | |||
| 419 | + | return consumeOSC(data) | |||
| 420 | + | case 'P': | |||
| 421 | + | return consumeSTString(data) | |||
| 422 | + | case '_', '^', 'X': | |||
| 423 | + | return consumeSTString(data) | |||
| 424 | + | case '[': | |||
| 425 | + | return s.consumeCSI(data) | |||
| 426 | + | case '=': | |||
| 427 | + | return 2, false | |||
| 428 | + | case '>': | |||
| 429 | + | return 2, false | |||
| 430 | + | case '7': | |||
| 431 | + | s.saveCursor() | |||
| 432 | + | return 2, false | |||
| 433 | + | case '8': | |||
| 434 | + | s.restoreCursor() | |||
| 435 | + | return 2, false | |||
| 436 | + | case 'D': | |||
| 437 | + | s.index() | |||
| 438 | + | return 2, false | |||
| 439 | + | case 'E': | |||
| 440 | + | s.nextLine() | |||
| 441 | + | return 2, false | |||
| 442 | + | case 'M': | |||
| 443 | + | s.reverseIndex() | |||
| 444 | + | return 2, false | |||
| 445 | + | case 'c': | |||
| 446 | + | s.reset() | |||
| 447 | + | return 2, false | |||
| 448 | + | case 'H': | |||
| 449 | + | return 2, false | |||
| 450 | + | case '(', ')', '*', '+', '-', '.', '/': | |||
| 451 | + | if len(data) < 3 { | |||
| 452 | + | return 0, true | |||
| 453 | + | } | |||
| 454 | + | return 3, false | |||
| 455 | + | default: | |||
| 456 | + | if data[1] >= 0x30 && data[1] <= 0x7e { | |||
| 457 | + | return 2, false | |||
| 458 | + | } | |||
| 459 | + | return 0, false | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) { | |||
| 464 | + | end := -1 | |||
| 465 | + | for i := 2; i < len(data); i++ { | |||
| 466 | + | if data[i] >= '@' && data[i] <= '~' { | |||
| 467 | + | end = i | |||
| 468 | + | break | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | if end == -1 { | |||
| 472 | + | return 0, true | |||
| 473 | + | } | |||
| 474 | + | params := string(data[2:end]) | |||
| 475 | + | cmd := data[end] | |||
| 476 | + | s.applyCSI(params, cmd) | |||
| 477 | + | return end + 1, false | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | func consumeOSC(data []byte) (consumed int, partial bool) { | |||
| 481 | + | for i := 2; i < len(data); i++ { | |||
| 482 | + | switch data[i] { | |||
| 483 | + | case 0x07: | |||
| 484 | + | return i + 1, false | |||
| 485 | + | case 0x1b: | |||
| 486 | + | if i+1 < len(data) && data[i+1] == '\\' { | |||
| 487 | + | return i + 2, false | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | return 0, true | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func consumeSTString(data []byte) (consumed int, partial bool) { | |||
| 495 | + | for i := 2; i < len(data); i++ { | |||
| 496 | + | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |||
| 497 | + | return i + 2, false | |||
| 498 | + | } | |||
| 499 | + | } | |||
| 500 | + | return 0, true | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 504 | + | private := strings.HasPrefix(params, "?") | |||
| 505 | + | parts := parseCSIInts(params) | |||
| 506 | + | switch cmd { | |||
| 507 | + | case 'm': | |||
| 508 | + | if strings.HasPrefix(params, ">") { | |||
| 509 | + | return | |||
| 510 | + | } | |||
| 511 | + | s.applySGR(params) | |||
| 512 | + | case 'J': | |||
| 513 | + | mode := 0 | |||
| 514 | + | if len(parts) > 0 { | |||
| 515 | + | mode = parts[0] | |||
| 516 | + | } | |||
| 517 | + | s.clearScreenMode(mode) | |||
| 518 | + | case 'K': | |||
| 519 | + | mode := 0 | |||
| 520 | + | if len(parts) > 0 { | |||
| 521 | + | mode = parts[0] | |||
| 522 | + | } | |||
| 523 | + | s.clearLineMode(mode) | |||
| 524 | + | case 'S': | |||
| 525 | + | s.scrollUpBy(firstPositive(parts, 1)) | |||
| 526 | + | case 'T': | |||
| 527 | + | s.scrollDownBy(firstPositive(parts, 1)) | |||
| 528 | + | case 'H', 'f': | |||
| 529 | + | row, col := 1, 1 | |||
| 530 | + | if len(parts) > 0 && parts[0] > 0 { | |||
| 531 | + | row = parts[0] | |||
| 532 | + | } | |||
| 533 | + | if len(parts) > 1 && parts[1] > 0 { | |||
| 534 | + | col = parts[1] | |||
| 535 | + | } | |||
| 536 | + | s.Row = clamp(row-1, 0, s.Frame.Height-1) | |||
| 537 | + | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |||
| 538 | + | case 'A': | |||
| 539 | + | s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1) | |||
| 540 | + | case 'B': | |||
| 541 | + | s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1) | |||
| 542 | + | case 'C': | |||
| 543 | + | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1) | |||
| 544 | + | case 'D': | |||
| 545 | + | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1) | |||
| 546 | + | case 'E': | |||
| 547 | + | s.Row = clamp(s.Row+firstPositive(parts, 1), 0, s.Frame.Height-1) | |||
| 548 | + | s.Col = 0 | |||
| 549 | + | case 'F': | |||
| 550 | + | s.Row = clamp(s.Row-firstPositive(parts, 1), 0, s.Frame.Height-1) | |||
| 551 | + | s.Col = 0 | |||
| 552 | + | case 'G': | |||
| 553 | + | col := firstPositive(parts, 1) | |||
| 554 | + | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |||
| 555 | + | case 'd': | |||
| 556 | + | row := firstPositive(parts, 1) | |||
| 557 | + | s.Row = clamp(row-1, 0, s.Frame.Height-1) | |||
| 558 | + | case '@': | |||
| 559 | + | s.insertChars(firstPositive(parts, 1)) | |||
| 560 | + | case 'P': | |||
| 561 | + | s.deleteChars(firstPositive(parts, 1)) | |||
| 562 | + | case 'L': | |||
| 563 | + | s.insertLines(firstPositive(parts, 1)) | |||
| 564 | + | case 'M': | |||
| 565 | + | s.deleteLines(firstPositive(parts, 1)) | |||
| 566 | + | case 'r': | |||
| 567 | + | s.setScrollRegion(parts) | |||
| 568 | + | case 's': | |||
| 569 | + | s.saveCursor() | |||
| 570 | + | case 'u': | |||
| 571 | + | s.restoreCursor() | |||
| 572 | + | case 'h': | |||
| 573 | + | s.setMode(private, parts, true) | |||
| 574 | + | case 'l': | |||
| 575 | + | s.setMode(private, parts, false) | |||
| 576 | + | } | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | func firstPositive(parts []int, fallback int) int { | |||
| 580 | + | if len(parts) == 0 || parts[0] <= 0 { | |||
| 581 | + | return fallback | |||
| 582 | + | } | |||
| 583 | + | return parts[0] | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | func (s *Screen) applySGR(params string) { | |||
| 587 | + | if params == "" { | |||
| 588 | + | s.Style = renderer.DefaultStyle() | |||
| 589 | + | return | |||
| 590 | + | } | |||
| 591 | + | parts := parseCSIInts(params) | |||
| 592 | + | for i := 0; i < len(parts); i++ { | |||
| 593 | + | switch parts[i] { | |||
| 594 | + | case 0: | |||
| 595 | + | s.Style = renderer.DefaultStyle() | |||
| 596 | + | case 1: | |||
| 597 | + | s.Style.Bold = true | |||
| 598 | + | case 7: | |||
| 599 | + | s.Style.Inverse = true | |||
| 600 | + | case 22: | |||
| 601 | + | s.Style.Bold = false | |||
| 602 | + | case 27: | |||
| 603 | + | s.Style.Inverse = false | |||
| 604 | + | case 30, 31, 32, 33, 34, 35, 36, 37: | |||
| 605 | + | s.Style.Foreground = parts[i] - 30 | |||
| 606 | + | s.Style.HasForegroundRGB = false | |||
| 607 | + | case 39: | |||
| 608 | + | s.Style.Foreground = -1 | |||
| 609 | + | s.Style.HasForegroundRGB = false | |||
| 610 | + | case 40, 41, 42, 43, 44, 45, 46, 47: | |||
| 611 | + | s.Style.Background = parts[i] - 40 | |||
| 612 | + | s.Style.HasBackgroundRGB = false | |||
| 613 | + | case 49: | |||
| 614 | + | s.Style.Background = -1 | |||
| 615 | + | s.Style.HasBackgroundRGB = false | |||
| 616 | + | case 90, 91, 92, 93, 94, 95, 96, 97: | |||
| 617 | + | s.Style.Foreground = parts[i] - 90 + 8 | |||
| 618 | + | s.Style.HasForegroundRGB = false | |||
| 619 | + | case 100, 101, 102, 103, 104, 105, 106, 107: | |||
| 620 | + | s.Style.Background = parts[i] - 100 + 8 | |||
| 621 | + | s.Style.HasBackgroundRGB = false | |||
| 622 | + | case 38: | |||
| 623 | + | if i+2 < len(parts) && parts[i+1] == 5 { | |||
| 624 | + | s.Style.Foreground = parts[i+2] | |||
| 625 | + | s.Style.HasForegroundRGB = false | |||
| 626 | + | i += 2 | |||
| 627 | + | } else if i+4 < len(parts) && parts[i+1] == 2 { | |||
| 628 | + | s.Style.Foreground = -1 | |||
| 629 | + | s.Style.HasForegroundRGB = true | |||
| 630 | + | s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |||
| 631 | + | i += 4 | |||
| 632 | + | } | |||
| 633 | + | case 48: | |||
| 634 | + | if i+2 < len(parts) && parts[i+1] == 5 { | |||
| 635 | + | s.Style.Background = parts[i+2] | |||
| 636 | + | s.Style.HasBackgroundRGB = false | |||
| 637 | + | i += 2 | |||
| 638 | + | } else if i+4 < len(parts) && parts[i+1] == 2 { | |||
| 639 | + | s.Style.Background = -1 | |||
| 640 | + | s.Style.HasBackgroundRGB = true | |||
| 641 | + | s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |||
| 642 | + | i += 4 | |||
| 643 | + | } | |||
| 644 | + | } | |||
| 645 | + | } | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | func sgrRGB(r, g, b int) renderer.RGB { | |||
| 649 | + | return renderer.RGB{ | |||
| 650 | + | R: uint8(clamp(r, 0, 255)), | |||
| 651 | + | G: uint8(clamp(g, 0, 255)), | |||
| 652 | + | B: uint8(clamp(b, 0, 255)), | |||
| 653 | + | } | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | func (s *Screen) saveCursor() { | |||
| 657 | + | s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, saved: true} | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | func (s *Screen) restoreCursor() { | |||
| 661 | + | if !s.savedCursor.saved { | |||
| 662 | + | return | |||
| 663 | + | } | |||
| 664 | + | s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1) | |||
| 665 | + | s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1) | |||
| 666 | + | s.Style = s.savedCursor.style | |||
| 667 | + | } | |||
| 668 | + | ||||
| 669 | + | func (s *Screen) reset() { | |||
| 670 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |||
| 671 | + | s.Row, s.Col = 0, 0 | |||
| 672 | + | s.Style = renderer.DefaultStyle() | |||
| 673 | + | s.escapeBuf = s.escapeBuf[:0] | |||
| 674 | + | s.savedCursor = cursorState{} | |||
| 675 | + | s.alternate = nil | |||
| 676 | + | s.resetScrollRegion() | |||
| 677 | + | s.fullRedraw() | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | func (s *Screen) setScrollRegion(parts []int) { | |||
| 681 | + | if s.Frame.Height == 0 { | |||
| 682 | + | return | |||
| 683 | + | } | |||
| 684 | + | top, bottom := 1, s.Frame.Height | |||
| 685 | + | if len(parts) > 0 && parts[0] > 0 { | |||
| 686 | + | top = parts[0] | |||
| 687 | + | } | |||
| 688 | + | if len(parts) > 1 && parts[1] > 0 { | |||
| 689 | + | bottom = parts[1] | |||
| 690 | + | } | |||
| 691 | + | if top >= bottom { | |||
| 692 | + | s.resetScrollRegion() | |||
| 693 | + | } else { | |||
| 694 | + | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |||
| 695 | + | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |||
| 696 | + | if s.scrollTop >= s.scrollBottom { | |||
| 697 | + | s.resetScrollRegion() | |||
| 698 | + | } | |||
| 699 | + | } | |||
| 700 | + | s.Row, s.Col = 0, 0 | |||
| 701 | + | } | |||
| 702 | + | ||||
| 703 | + | func (s *Screen) resetScrollRegion() { | |||
| 704 | + | s.scrollTop = 0 | |||
| 705 | + | if s.Frame.Height > 0 { | |||
| 706 | + | s.scrollBottom = s.Frame.Height - 1 | |||
| 707 | + | } else { | |||
| 708 | + | s.scrollBottom = 0 | |||
| 709 | + | } | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 713 | + | if !private { | |||
| 714 | + | return | |||
| 715 | + | } | |||
| 716 | + | for _, mode := range parts { | |||
| 717 | + | switch mode { | |||
| 718 | + | case 47, 1047, 1049: | |||
| 719 | + | if enabled { | |||
| 720 | + | s.enterAlternateScreen() | |||
| 721 | + | } else { | |||
| 722 | + | s.exitAlternateScreen() | |||
| 723 | + | } | |||
| 724 | + | case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 2004, 2026: | |||
| 725 | + | // Trackable terminal modes that do not directly affect the current | |||
| 726 | + | // cell model yet. Consuming them prevents mode bytes from leaking. | |||
| 727 | + | continue | |||
| 728 | + | } | |||
| 729 | + | } | |||
| 730 | + | } | |||
| 731 | + | ||||
| 732 | + | func (s *Screen) enterAlternateScreen() { | |||
| 733 | + | if s.alternate == nil { | |||
| 734 | + | s.alternate = &screenState{ | |||
| 735 | + | frame: cloneFrame(s.Frame), | |||
| 736 | + | row: s.Row, | |||
| 737 | + | col: s.Col, | |||
| 738 | + | style: s.Style, | |||
| 739 | + | scrollTop: s.scrollTop, | |||
| 740 | + | scrollBottom: s.scrollBottom, | |||
| 741 | + | savedCursor: s.savedCursor, | |||
| 742 | + | } | |||
| 743 | + | } | |||
| 744 | + | s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height) | |||
| 745 | + | s.Row, s.Col = 0, 0 | |||
| 746 | + | s.Style = renderer.DefaultStyle() | |||
| 747 | + | s.savedCursor = cursorState{} | |||
| 748 | + | s.resetScrollRegion() | |||
| 749 | + | s.fullRedraw() | |||
| 750 | + | } | |||
| 751 | + | ||||
| 752 | + | func (s *Screen) exitAlternateScreen() { | |||
| 753 | + | if s.alternate == nil { | |||
| 754 | + | return | |||
| 755 | + | } | |||
| 756 | + | state := s.alternate | |||
| 757 | + | s.Frame = cloneFrame(state.frame) | |||
| 758 | + | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |||
| 759 | + | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |||
| 760 | + | s.Style = state.style | |||
| 761 | + | s.scrollTop = state.scrollTop | |||
| 762 | + | s.scrollBottom = state.scrollBottom | |||
| 763 | + | s.savedCursor = state.savedCursor | |||
| 764 | + | s.alternate = nil | |||
| 765 | + | s.fullRedraw() | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | func cloneFrame(frame renderer.Frame) renderer.Frame { | |||
| 769 | + | out := renderer.NewFrame(frame.Width, frame.Height) | |||
| 770 | + | copy(out.Cells, frame.Cells) | |||
| 771 | + | return out | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | func (s *Screen) insertChars(n int) { | |||
| 775 | + | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | |||
| 776 | + | return | |||
| 777 | + | } | |||
| 778 | + | w := s.Frame.Width | |||
| 779 | + | if n > w-s.Col { | |||
| 780 | + | n = w - s.Col | |||
| 781 | + | } | |||
| 782 | + | rowStart := s.Row * w | |||
| 783 | + | for x := w - 1; x >= s.Col+n; x-- { | |||
| 784 | + | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x-n] | |||
| 785 | + | } | |||
| 786 | + | for x := s.Col; x < s.Col+n; x++ { | |||
| 787 | + | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | |||
| 788 | + | } | |||
| 789 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1}) | |||
| 790 | + | } | |||
| 791 | + | ||||
| 792 | + | func (s *Screen) deleteChars(n int) { | |||
| 793 | + | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | |||
| 794 | + | return | |||
| 795 | + | } | |||
| 796 | + | w := s.Frame.Width | |||
| 797 | + | if n > w-s.Col { | |||
| 798 | + | n = w - s.Col | |||
| 799 | + | } | |||
| 800 | + | rowStart := s.Row * w | |||
| 801 | + | for x := s.Col; x < w-n; x++ { | |||
| 802 | + | s.Frame.Cells[rowStart+x] = s.Frame.Cells[rowStart+x+n] | |||
| 803 | + | } | |||
| 804 | + | for x := w - n; x < w; x++ { | |||
| 805 | + | s.Frame.Cells[rowStart+x] = renderer.BlankCell() | |||
| 806 | + | } | |||
| 807 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: s.Col, Y: s.Row, Width: w - s.Col, Height: 1, Count: 1}) | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | func (s *Screen) insertLines(n int) { | |||
| 811 | + | if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 { | |||
| 812 | + | return | |||
| 813 | + | } | |||
| 814 | + | s.scrollDownRegion(s.Row, s.scrollBottom, n) | |||
| 815 | + | } | |||
| 816 | + | ||||
| 817 | + | func (s *Screen) deleteLines(n int) { | |||
| 818 | + | if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 { | |||
| 819 | + | return | |||
| 820 | + | } | |||
| 821 | + | s.scrollUpRegion(s.Row, s.scrollBottom, n) | |||
| 822 | + | } | |||
| 823 | + | ||||
| 824 | + | func (s *Screen) clearScreenMode(mode int) { | |||
| 825 | + | s.clampCursor() | |||
| 826 | + | switch mode { | |||
| 827 | + | case 1: | |||
| 828 | + | for y := 0; y <= s.Row && y < s.Frame.Height; y++ { | |||
| 829 | + | end := s.Frame.Width | |||
| 830 | + | if y == s.Row { | |||
| 831 | + | end = min(s.Col+1, s.Frame.Width) | |||
| 832 | + | } | |||
| 833 | + | for x := 0; x < end; x++ { | |||
| 834 | + | s.Frame.Set(x, y, renderer.BlankCell()) | |||
| 835 | + | } | |||
| 836 | + | } | |||
| 837 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1}) | |||
| 838 | + | case 2, 3: | |||
| 839 | + | for i := range s.Frame.Cells { | |||
| 840 | + | s.Frame.Cells[i] = renderer.BlankCell() | |||
| 841 | + | } | |||
| 842 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |||
| 843 | + | default: | |||
| 844 | + | for y := s.Row; y < s.Frame.Height; y++ { | |||
| 845 | + | start := 0 | |||
| 846 | + | if y == s.Row { | |||
| 847 | + | start = s.Col | |||
| 848 | + | } | |||
| 849 | + | for x := start; x < s.Frame.Width; x++ { | |||
| 850 | + | s.Frame.Set(x, y, renderer.BlankCell()) | |||
| 851 | + | } | |||
| 852 | + | } | |||
| 853 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1}) | |||
| 854 | + | } | |||
| 855 | + | } | |||
| 856 | + | ||||
| 857 | + | func (s *Screen) clearLineMode(mode int) { | |||
| 858 | + | s.clampCursor() | |||
| 859 | + | switch mode { | |||
| 860 | + | case 1: | |||
| 861 | + | end := min(s.Col+1, s.Frame.Width) | |||
| 862 | + | for x := 0; x < end; x++ { | |||
| 863 | + | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |||
| 864 | + | } | |||
| 865 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: end, Height: 1, Count: 1}) | |||
| 866 | + | case 2: | |||
| 867 | + | for x := 0; x < s.Frame.Width; x++ { | |||
| 868 | + | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |||
| 869 | + | } | |||
| 870 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: 1, Count: 1}) | |||
| 871 | + | default: | |||
| 872 | + | for x := s.Col; x < s.Frame.Width; x++ { | |||
| 873 | + | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |||
| 874 | + | } | |||
| 875 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: s.Col, Y: s.Row, Width: s.Frame.Width - s.Col, Height: 1, Count: 1}) | |||
| 876 | + | } | |||
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | func (s *Screen) clampCursor() { | |||
| 880 | + | s.Row = clamp(s.Row, 0, s.Frame.Height-1) | |||
| 881 | + | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | |||
| 882 | + | } | |||
| 883 | + | ||||
| 884 | + | func parseCSIInts(params string) []int { | |||
| 885 | + | if params == "" { | |||
| 886 | + | return nil | |||
| 887 | + | } | |||
| 888 | + | params = strings.TrimPrefix(params, "?") | |||
| 889 | + | params = strings.TrimPrefix(params, ">") | |||
| 890 | + | parts := strings.Split(params, ";") | |||
| 891 | + | out := make([]int, 0, len(parts)) | |||
| 892 | + | for _, p := range parts { | |||
| 893 | + | if p == "" { | |||
| 894 | + | out = append(out, 0) | |||
| 895 | + | continue | |||
| 896 | + | } | |||
| 897 | + | v, err := strconv.Atoi(p) | |||
| 898 | + | if err != nil { | |||
| 899 | + | out = append(out, 0) | |||
| 900 | + | continue | |||
| 901 | + | } | |||
| 902 | + | out = append(out, v) | |||
| 903 | + | } | |||
| 904 | + | return out | |||
| 905 | + | } | |||
| 906 | + | ||||
| 907 | + | func clamp(v, lo, hi int) int { | |||
| 908 | + | if hi < lo { | |||
| 909 | + | return lo | |||
| 910 | + | } | |||
| 911 | + | if v < lo { | |||
| 912 | + | return lo | |||
| 913 | + | } | |||
| 914 | + | if v > hi { | |||
| 915 | + | return hi | |||
| 916 | + | } | |||
| 917 | + | return v | |||
| 918 | + | } | |||
A pkg/vt/screen_test.go +1537 -0
| @@ -0,0 +1,1537 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // --------------------------------------------------------------------------- | |
| 11 | + | // Helpers | |
| 12 | + | // --------------------------------------------------------------------------- | |
| 13 | + | ||
| 14 | + | func damageKinds(d []renderer.Damage) []renderer.DamageKind { | |
| 15 | + | ks := make([]renderer.DamageKind, len(d)) | |
| 16 | + | for i, dd := range d { | |
| 17 | + | ks[i] = dd.Kind | |
| 18 | + | } | |
| 19 | + | return ks | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool { | |
| 23 | + | for _, dd := range d { | |
| 24 | + | if dd.Kind == kind { | |
| 25 | + | return true | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | return false | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func cellAt(s *Screen, x, y int) renderer.Cell { | |
| 32 | + | return s.Frame.At(x, y) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func assertCell(t *testing.T, s *Screen, x, y int, expected rune) { | |
| 36 | + | t.Helper() | |
| 37 | + | c := cellAt(s, x, y) | |
| 38 | + | if c.Rune != expected { | |
| 39 | + | t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func lineText(s *Screen, y int) string { | |
| 44 | + | out := make([]rune, s.Frame.Width) | |
| 45 | + | for x := range s.Frame.Width { | |
| 46 | + | out[x] = s.Frame.At(x, y).Rune | |
| 47 | + | } | |
| 48 | + | return string(out) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // --------------------------------------------------------------------------- | |
| 52 | + | // Printable ASCII / UTF-8 | |
| 53 | + | // --------------------------------------------------------------------------- | |
| 54 | + | ||
| 55 | + | func TestWritePrintable(t *testing.T) { | |
| 56 | + | s := NewScreen(5, 3) | |
| 57 | + | s.Write([]byte("Hi")) | |
| 58 | + | ||
| 59 | + | assertCell(t, s, 0, 0, 'H') | |
| 60 | + | assertCell(t, s, 1, 0, 'i') | |
| 61 | + | if s.Col != 2 || s.Row != 0 { | |
| 62 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Should have damage. | |
| 66 | + | d := s.Damage() | |
| 67 | + | if len(d) == 0 { | |
| 68 | + | t.Fatal("expected damage after writing") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestWriteUTF8(t *testing.T) { | |
| 73 | + | s := NewScreen(10, 3) | |
| 74 | + | s.Write([]byte("aäéø©")) | |
| 75 | + | ||
| 76 | + | assertCell(t, s, 0, 0, 'a') | |
| 77 | + | assertCell(t, s, 1, 0, 'ä') | |
| 78 | + | assertCell(t, s, 2, 0, 'é') | |
| 79 | + | assertCell(t, s, 3, 0, 'ø') | |
| 80 | + | assertCell(t, s, 4, 0, '©') | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestWritePrintableBeyondWidthWraps(t *testing.T) { | |
| 84 | + | s := NewScreen(4, 2) | |
| 85 | + | s.Write([]byte("ABCDE")) | |
| 86 | + | ||
| 87 | + | assertCell(t, s, 0, 0, 'A') | |
| 88 | + | assertCell(t, s, 1, 0, 'B') | |
| 89 | + | assertCell(t, s, 2, 0, 'C') | |
| 90 | + | assertCell(t, s, 3, 0, 'D') | |
| 91 | + | // E wrapped to next line. | |
| 92 | + | assertCell(t, s, 0, 1, 'E') | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestWritePrintableBeyondScreenScrolls(t *testing.T) { | |
| 96 | + | s := NewScreen(3, 2) | |
| 97 | + | // Fill both lines. | |
| 98 | + | s.Write([]byte("ABC")) | |
| 99 | + | s.Write([]byte("DEF")) | |
| 100 | + | // Row is now 1 (bottom). One more char should scroll. | |
| 101 | + | s.Write([]byte("G")) | |
| 102 | + | ||
| 103 | + | // After scroll: old row 0 = "DEF", new row 1 = "G ". | |
| 104 | + | assertCell(t, s, 0, 0, 'D') | |
| 105 | + | assertCell(t, s, 1, 0, 'E') | |
| 106 | + | assertCell(t, s, 2, 0, 'F') | |
| 107 | + | assertCell(t, s, 0, 1, 'G') | |
| 108 | + | assertCell(t, s, 1, 1, ' ') | |
| 109 | + | ||
| 110 | + | // Check scroll damage. | |
| 111 | + | d := s.Damage() | |
| 112 | + | if !hasDamageKind(d, renderer.DamageScrollUp) { | |
| 113 | + | t.Error("expected scroll damage") | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // --------------------------------------------------------------------------- | |
| 118 | + | // Carriage Return | |
| 119 | + | // --------------------------------------------------------------------------- | |
| 120 | + | ||
| 121 | + | func TestCR(t *testing.T) { | |
| 122 | + | s := NewScreen(10, 3) | |
| 123 | + | s.Write([]byte("Hello")) | |
| 124 | + | // CR moves column back to 0. | |
| 125 | + | s.Write([]byte("\rWorld")) | |
| 126 | + | ||
| 127 | + | assertCell(t, s, 0, 0, 'W') | |
| 128 | + | assertCell(t, s, 1, 0, 'o') | |
| 129 | + | assertCell(t, s, 2, 0, 'r') | |
| 130 | + | assertCell(t, s, 3, 0, 'l') | |
| 131 | + | assertCell(t, s, 4, 0, 'd') | |
| 132 | + | if s.Col != 5 { | |
| 133 | + | t.Errorf("col after CR + World = %d, want 5", s.Col) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // --------------------------------------------------------------------------- | |
| 138 | + | // Line Feed | |
| 139 | + | // --------------------------------------------------------------------------- | |
| 140 | + | ||
| 141 | + | func TestLF(t *testing.T) { | |
| 142 | + | s := NewScreen(5, 3) | |
| 143 | + | s.Write([]byte("A\r\nB\r\nC")) | |
| 144 | + | ||
| 145 | + | assertCell(t, s, 0, 0, 'A') | |
| 146 | + | assertCell(t, s, 0, 1, 'B') | |
| 147 | + | assertCell(t, s, 0, 2, 'C') | |
| 148 | + | if s.Row != 2 || s.Col != 1 { | |
| 149 | + | t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestLFDoesNotResetColumn(t *testing.T) { | |
| 154 | + | s := NewScreen(5, 3) | |
| 155 | + | s.Write([]byte("AB\nC")) | |
| 156 | + | ||
| 157 | + | assertCell(t, s, 0, 0, 'A') | |
| 158 | + | assertCell(t, s, 1, 0, 'B') | |
| 159 | + | assertCell(t, s, 0, 1, ' ') | |
| 160 | + | assertCell(t, s, 2, 1, 'C') | |
| 161 | + | if s.Row != 1 || s.Col != 3 { | |
| 162 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestLFAtPendingWrapBoundaryAdvancesOneLine(t *testing.T) { | |
| 167 | + | s := NewScreen(5, 4) | |
| 168 | + | s.Write([]byte("ABCDE\nZ")) | |
| 169 | + | ||
| 170 | + | assertCell(t, s, 4, 0, 'E') | |
| 171 | + | assertCell(t, s, 4, 1, 'Z') | |
| 172 | + | if s.Row != 1 || s.Col != 5 { | |
| 173 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestLFScroll(t *testing.T) { | |
| 178 | + | s := NewScreen(5, 2) | |
| 179 | + | // Fill both lines then CRLF to scroll and return to column 0. | |
| 180 | + | s.Write([]byte("AAAAA")) | |
| 181 | + | s.Write([]byte("BBBBB")) | |
| 182 | + | s.Write([]byte("\r\nCCCCC")) | |
| 183 | + | ||
| 184 | + | assertCell(t, s, 0, 0, 'B') | |
| 185 | + | assertCell(t, s, 4, 1, 'C') | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // --------------------------------------------------------------------------- | |
| 189 | + | // Backspace | |
| 190 | + | // --------------------------------------------------------------------------- | |
| 191 | + | ||
| 192 | + | func TestBS(t *testing.T) { | |
| 193 | + | s := NewScreen(10, 3) | |
| 194 | + | s.Write([]byte("Hello\b")) | |
| 195 | + | ||
| 196 | + | if s.Col != 4 { | |
| 197 | + | t.Errorf("col after BS = %d, want 4", s.Col) | |
| 198 | + | } | |
| 199 | + | assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestBSAtColumnZero(t *testing.T) { | |
| 203 | + | s := NewScreen(10, 3) | |
| 204 | + | s.Write([]byte("\b")) // BS at col 0 should be no-op | |
| 205 | + | ||
| 206 | + | if s.Col != 0 { | |
| 207 | + | t.Errorf("col = %d, want 0", s.Col) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // --------------------------------------------------------------------------- | |
| 212 | + | // Tab | |
| 213 | + | // --------------------------------------------------------------------------- | |
| 214 | + | ||
| 215 | + | func TestTab(t *testing.T) { | |
| 216 | + | s := NewScreen(20, 3) | |
| 217 | + | s.Write([]byte("A\tB")) | |
| 218 | + | ||
| 219 | + | assertCell(t, s, 0, 0, 'A') | |
| 220 | + | // Tab advances to next 8-column boundary (col 8). | |
| 221 | + | for i := 1; i < 8; i++ { | |
| 222 | + | assertCell(t, s, i, 0, ' ') | |
| 223 | + | } | |
| 224 | + | assertCell(t, s, 8, 0, 'B') | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // --------------------------------------------------------------------------- | |
| 228 | + | // CSI SGR | |
| 229 | + | // --------------------------------------------------------------------------- | |
| 230 | + | ||
| 231 | + | func TestSGRDefault(t *testing.T) { | |
| 232 | + | s := NewScreen(10, 3) | |
| 233 | + | s.Write([]byte("\x1b[0m")) | |
| 234 | + | ||
| 235 | + | if s.Style != (renderer.DefaultStyle()) { | |
| 236 | + | t.Errorf("style after SGR 0 = %+v, want default", s.Style) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestSGRBold(t *testing.T) { | |
| 241 | + | s := NewScreen(10, 3) | |
| 242 | + | s.Write([]byte("\x1b[1m")) | |
| 243 | + | ||
| 244 | + | if !s.Style.Bold { | |
| 245 | + | t.Error("expected bold") | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestSGRInverse(t *testing.T) { | |
| 250 | + | s := NewScreen(10, 3) | |
| 251 | + | s.Write([]byte("\x1b[7m")) | |
| 252 | + | ||
| 253 | + | if !s.Style.Inverse { | |
| 254 | + | t.Error("expected inverse") | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func TestSGRForeground(t *testing.T) { | |
| 259 | + | s := NewScreen(10, 3) | |
| 260 | + | s.Write([]byte("\x1b[31m")) // red | |
| 261 | + | ||
| 262 | + | if s.Style.Foreground != 1 { | |
| 263 | + | t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestSGRBackground(t *testing.T) { | |
| 268 | + | s := NewScreen(10, 3) | |
| 269 | + | s.Write([]byte("\x1b[44m")) // blue background | |
| 270 | + | ||
| 271 | + | if s.Style.Background != 4 { | |
| 272 | + | t.Errorf("background = %d, want 4 (blue)", s.Style.Background) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestSGR256Color(t *testing.T) { | |
| 277 | + | s := NewScreen(10, 3) | |
| 278 | + | s.Write([]byte("\x1b[38;5;82m")) // 256-color foreground | |
| 279 | + | ||
| 280 | + | if s.Style.Foreground != 82 { | |
| 281 | + | t.Errorf("foreground = %d, want 82", s.Style.Foreground) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func TestSGR256Background(t *testing.T) { | |
| 286 | + | s := NewScreen(10, 3) | |
| 287 | + | s.Write([]byte("\x1b[48;5;200m")) // 256-color background | |
| 288 | + | ||
| 289 | + | if s.Style.Background != 200 { | |
| 290 | + | t.Errorf("background = %d, want 200", s.Style.Background) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestSGRBrightForeground(t *testing.T) { | |
| 295 | + | s := NewScreen(10, 3) | |
| 296 | + | s.Write([]byte("\x1b[91m")) // bright red | |
| 297 | + | ||
| 298 | + | if s.Style.Foreground != 9 { | |
| 299 | + | t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestSGRBrightBackground(t *testing.T) { | |
| 304 | + | s := NewScreen(10, 3) | |
| 305 | + | s.Write([]byte("\x1b[107m")) // bright white background | |
| 306 | + | ||
| 307 | + | if s.Style.Background != 15 { | |
| 308 | + | t.Errorf("background = %d, want 15 (bright white)", s.Style.Background) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func TestSGRTruecolorForeground(t *testing.T) { | |
| 313 | + | s := NewScreen(10, 3) | |
| 314 | + | s.Write([]byte("\x1b[38;2;12;34;56mX")) | |
| 315 | + | ||
| 316 | + | want := renderer.RGB{R: 12, G: 34, B: 56} | |
| 317 | + | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |
| 318 | + | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |
| 319 | + | } | |
| 320 | + | if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { | |
| 321 | + | t.Errorf("cell foreground RGB = %+v, want %+v", got, want) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func TestSGRTruecolorBackground(t *testing.T) { | |
| 326 | + | s := NewScreen(10, 3) | |
| 327 | + | s.Write([]byte("\x1b[48;2;200;100;50m")) | |
| 328 | + | ||
| 329 | + | want := renderer.RGB{R: 200, G: 100, B: 50} | |
| 330 | + | if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want { | |
| 331 | + | t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func TestSGRDefaultForegroundClearsTruecolor(t *testing.T) { | |
| 336 | + | s := NewScreen(10, 3) | |
| 337 | + | s.Write([]byte("\x1b[38;2;12;34;56m\x1b[39m")) | |
| 338 | + | ||
| 339 | + | if s.Style.HasForegroundRGB || s.Style.Foreground != -1 { | |
| 340 | + | t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestSGRTruecolorClampsComponents(t *testing.T) { | |
| 345 | + | s := NewScreen(10, 3) | |
| 346 | + | s.Write([]byte("\x1b[38;2;-1;300;42m")) | |
| 347 | + | ||
| 348 | + | want := renderer.RGB{R: 0, G: 255, B: 42} | |
| 349 | + | if s.Style.ForegroundRGB != want { | |
| 350 | + | t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) { | |
| 355 | + | tests := []struct { | |
| 356 | + | name string | |
| 357 | + | seq string | |
| 358 | + | foreground int | |
| 359 | + | background int | |
| 360 | + | hasForeground bool | |
| 361 | + | hasBackground bool | |
| 362 | + | }{ | |
| 363 | + | {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1}, | |
| 364 | + | {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1}, | |
| 365 | + | {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true}, | |
| 366 | + | {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15}, | |
| 367 | + | {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200}, | |
| 368 | + | {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1}, | |
| 369 | + | {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true}, | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | for _, tt := range tests { | |
| 373 | + | t.Run(tt.name, func(t *testing.T) { | |
| 374 | + | s := NewScreen(10, 3) | |
| 375 | + | s.Write([]byte(tt.seq)) | |
| 376 | + | ||
| 377 | + | if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground { | |
| 378 | + | t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground) | |
| 379 | + | } | |
| 380 | + | if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground { | |
| 381 | + | t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground) | |
| 382 | + | } | |
| 383 | + | }) | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func TestSGRResetBold(t *testing.T) { | |
| 388 | + | s := NewScreen(10, 3) | |
| 389 | + | s.Write([]byte("\x1b[1mHello\x1b[22mWorld")) | |
| 390 | + | ||
| 391 | + | if s.Style.Bold { | |
| 392 | + | t.Error("bold should be reset after 22") | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestSGRResetInverse(t *testing.T) { | |
| 397 | + | s := NewScreen(10, 3) | |
| 398 | + | s.Write([]byte("\x1b[7m\x1b[27m")) | |
| 399 | + | ||
| 400 | + | if s.Style.Inverse { | |
| 401 | + | t.Error("inverse should be reset after 27") | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func TestSGRDefaultForeground(t *testing.T) { | |
| 406 | + | s := NewScreen(10, 3) | |
| 407 | + | s.Write([]byte("\x1b[31m\x1b[39m")) | |
| 408 | + | ||
| 409 | + | if s.Style.Foreground != -1 { | |
| 410 | + | t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground) | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestSGRDefaultBackground(t *testing.T) { | |
| 415 | + | s := NewScreen(10, 3) | |
| 416 | + | s.Write([]byte("\x1b[44m\x1b[49m")) | |
| 417 | + | ||
| 418 | + | if s.Style.Background != -1 { | |
| 419 | + | t.Errorf("background should be default after 49, got %d", s.Style.Background) | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func TestSGRAppliesToCells(t *testing.T) { | |
| 424 | + | s := NewScreen(10, 3) | |
| 425 | + | s.Write([]byte("\x1b[1;31mX")) | |
| 426 | + | ||
| 427 | + | c := cellAt(s, 0, 0) | |
| 428 | + | if !c.Style.Bold { | |
| 429 | + | t.Error("cell should be bold") | |
| 430 | + | } | |
| 431 | + | if c.Style.Foreground != 1 { | |
| 432 | + | t.Errorf("cell foreground = %d, want 1", c.Style.Foreground) | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // --------------------------------------------------------------------------- | |
| 437 | + | // Clear screen | |
| 438 | + | // --------------------------------------------------------------------------- | |
| 439 | + | ||
| 440 | + | func TestClearScreen(t *testing.T) { | |
| 441 | + | s := NewScreen(5, 3) | |
| 442 | + | s.Write([]byte("HelloWorldABCDE")) | |
| 443 | + | // Clear damage from writing. | |
| 444 | + | s.ClearDamage() | |
| 445 | + | ||
| 446 | + | s.Write([]byte("\x1b[2J")) | |
| 447 | + | ||
| 448 | + | // All cells should be blank. | |
| 449 | + | for y := 0; y < 3; y++ { | |
| 450 | + | for x := 0; x < 5; x++ { | |
| 451 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |
| 452 | + | t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune) | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | d := s.Damage() | |
| 458 | + | if !hasDamageKind(d, renderer.DamageClear) { | |
| 459 | + | t.Error("expected DamageClear after CSI 2 J") | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | // --------------------------------------------------------------------------- | |
| 464 | + | // Clear line | |
| 465 | + | // --------------------------------------------------------------------------- | |
| 466 | + | ||
| 467 | + | func TestClearLine(t *testing.T) { | |
| 468 | + | s := NewScreen(10, 3) | |
| 469 | + | s.Write([]byte("HelloWorld")) | |
| 470 | + | s.ClearDamage() | |
| 471 | + | ||
| 472 | + | // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end. | |
| 473 | + | s.Write([]byte("\x1b[1;6H")) | |
| 474 | + | s.Write([]byte("\x1b[K")) | |
| 475 | + | ||
| 476 | + | for x := 5; x < 10; x++ { | |
| 477 | + | if c := cellAt(s, x, 0); c.Rune != ' ' { | |
| 478 | + | t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune) | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | // First 5 chars should remain. | |
| 482 | + | assertCell(t, s, 0, 0, 'H') | |
| 483 | + | ||
| 484 | + | d := s.Damage() | |
| 485 | + | if !hasDamageKind(d, renderer.DamageClear) { | |
| 486 | + | t.Error("expected DamageClear after CSI K") | |
| 487 | + | } | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // --------------------------------------------------------------------------- | |
| 491 | + | // Cursor move | |
| 492 | + | // --------------------------------------------------------------------------- | |
| 493 | + | ||
| 494 | + | func TestCursorMoveH(t *testing.T) { | |
| 495 | + | s := NewScreen(10, 5) | |
| 496 | + | s.Write([]byte("\x1b[3;4H")) | |
| 497 | + | ||
| 498 | + | if s.Row != 2 || s.Col != 3 { | |
| 499 | + | t.Errorf("cursor at row=%d col=%d, want row=2 col=3", s.Row, s.Col) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func TestCursorMoveOutsideBounds(t *testing.T) { | |
| 504 | + | s := NewScreen(10, 5) | |
| 505 | + | s.Write([]byte("\x1b[100;200H")) | |
| 506 | + | ||
| 507 | + | // Should clamp to bottom-right. | |
| 508 | + | if s.Row != 4 || s.Col != 9 { | |
| 509 | + | t.Errorf("cursor at row=%d col=%d, want row=4 col=9", s.Row, s.Col) | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func TestCursorMoveF(t *testing.T) { | |
| 514 | + | s := NewScreen(10, 5) | |
| 515 | + | s.Write([]byte("\x1b[2;8f")) | |
| 516 | + | ||
| 517 | + | if s.Row != 1 || s.Col != 7 { | |
| 518 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=7", s.Row, s.Col) | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func TestCursorMoveForwardC(t *testing.T) { | |
| 523 | + | s := NewScreen(10, 5) | |
| 524 | + | s.Write([]byte("\r\x1b[3C")) | |
| 525 | + | ||
| 526 | + | if s.Row != 0 || s.Col != 3 { | |
| 527 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col) | |
| 528 | + | } | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | func TestESC_DECKPAMIgnored(t *testing.T) { | |
| 532 | + | s := NewScreen(10, 2) | |
| 533 | + | s.Write([]byte("\x1b=abc")) | |
| 534 | + | ||
| 535 | + | assertCell(t, s, 0, 0, 'a') | |
| 536 | + | assertCell(t, s, 1, 0, 'b') | |
| 537 | + | assertCell(t, s, 2, 0, 'c') | |
| 538 | + | if s.Col != 3 || s.Row != 0 { | |
| 539 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col) | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func TestESC_SaveRestoreCursor(t *testing.T) { | |
| 544 | + | s := NewScreen(10, 3) | |
| 545 | + | s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X")) | |
| 546 | + | ||
| 547 | + | assertCell(t, s, 0, 0, 'a') | |
| 548 | + | assertCell(t, s, 1, 0, 'b') | |
| 549 | + | assertCell(t, s, 2, 0, 'c') | |
| 550 | + | assertCell(t, s, 3, 0, 'X') | |
| 551 | + | assertCell(t, s, 4, 1, 'Z') | |
| 552 | + | assertCell(t, s, 5, 1, 'Z') | |
| 553 | + | if s.Row != 0 || s.Col != 4 { | |
| 554 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col) | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | func TestESC_IndexNextLineAndReverseIndex(t *testing.T) { | |
| 559 | + | s := NewScreen(5, 3) | |
| 560 | + | s.Write([]byte("A\x1bDB\x1bEC")) | |
| 561 | + | ||
| 562 | + | assertCell(t, s, 0, 0, 'A') | |
| 563 | + | assertCell(t, s, 1, 1, 'B') | |
| 564 | + | assertCell(t, s, 0, 2, 'C') | |
| 565 | + | ||
| 566 | + | s = NewScreen(5, 3) | |
| 567 | + | s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop")) | |
| 568 | + | assertCell(t, s, 0, 0, 't') | |
| 569 | + | assertCell(t, s, 0, 1, 'm') | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func TestCSI_CursorDirectionalMoves(t *testing.T) { | |
| 573 | + | s := NewScreen(10, 4) | |
| 574 | + | s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>")) | |
| 575 | + | ||
| 576 | + | assertCell(t, s, 4, 2, 'X') | |
| 577 | + | assertCell(t, s, 3, 2, '<') | |
| 578 | + | assertCell(t, s, 4, 1, '^') | |
| 579 | + | assertCell(t, s, 5, 2, 'v') | |
| 580 | + | assertCell(t, s, 9, 2, '>') | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | func TestCSI_InsertDeleteCharacters(t *testing.T) { | |
| 584 | + | s := NewScreen(8, 2) | |
| 585 | + | s.Write([]byte("abcdef")) | |
| 586 | + | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | |
| 587 | + | if got := lineText(s, 0); got != "abXYcdef" { | |
| 588 | + | t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef") | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | s.Write([]byte("\x1b[1;4H\x1b[3P")) | |
| 592 | + | if got := lineText(s, 0); got != "abXef " { | |
| 593 | + | t.Fatalf("line after DCH = %q, want %q", got, "abXef ") | |
| 594 | + | } | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | func TestCSI_InsertDeleteLines(t *testing.T) { | |
| 598 | + | s := NewScreen(5, 4) | |
| 599 | + | s.Write([]byte("11111222223333344444")) | |
| 600 | + | s.Write([]byte("\x1b[2;1H\x1b[L")) | |
| 601 | + | if got := lineText(s, 1); got != " " { | |
| 602 | + | t.Fatalf("inserted line = %q, want blank", got) | |
| 603 | + | } | |
| 604 | + | if got := lineText(s, 2); got != "22222" { | |
| 605 | + | t.Fatalf("shifted line = %q, want 22222", got) | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | s.Write([]byte("\x1b[2;1H\x1b[M")) | |
| 609 | + | if got := lineText(s, 1); got != "22222" { | |
| 610 | + | t.Fatalf("line after delete = %q, want 22222", got) | |
| 611 | + | } | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | func TestCSI_ScrollRegion(t *testing.T) { | |
| 615 | + | s := NewScreen(5, 4) | |
| 616 | + | s.Write([]byte("11111222223333344444")) | |
| 617 | + | s.Write([]byte("\x1b[2;3r\x1b[3;1H\n")) | |
| 618 | + | ||
| 619 | + | if got := lineText(s, 0); got != "11111" { | |
| 620 | + | t.Fatalf("row outside region changed: %q", got) | |
| 621 | + | } | |
| 622 | + | if got := lineText(s, 1); got != "33333" { | |
| 623 | + | t.Fatalf("region did not scroll up, row 1 = %q", got) | |
| 624 | + | } | |
| 625 | + | if got := lineText(s, 2); got != " " { | |
| 626 | + | t.Fatalf("region bottom not blanked, row 2 = %q", got) | |
| 627 | + | } | |
| 628 | + | if got := lineText(s, 3); got != "44444" { | |
| 629 | + | t.Fatalf("row below region changed: %q", got) | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | func TestCSI_PrivateAlternateScreenRestoresNormalScreen(t *testing.T) { | |
| 634 | + | s := NewScreen(20, 5) | |
| 635 | + | s.Write([]byte("normal-line")) | |
| 636 | + | s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP")) | |
| 637 | + | s.Write([]byte("\x1b[?1049lafter")) | |
| 638 | + | ||
| 639 | + | if got := lineText(s, 0); got != "normal-lineafter " { | |
| 640 | + | t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got) | |
| 641 | + | } | |
| 642 | + | if got := lineText(s, 2); got != " " { | |
| 643 | + | t.Fatalf("alternate content leaked into row 2: %q", got) | |
| 644 | + | } | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | func TestRendererStaysInSyncAfterVTEditDamage(t *testing.T) { | |
| 648 | + | s := NewScreen(8, 2) | |
| 649 | + | r := renderer.New(renderer.Capabilities{}) | |
| 650 | + | s.Write([]byte("abcdef")) | |
| 651 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |
| 652 | + | t.Fatal(err) | |
| 653 | + | } | |
| 654 | + | s.ClearDamage() | |
| 655 | + | ||
| 656 | + | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | |
| 657 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |
| 658 | + | t.Fatal(err) | |
| 659 | + | } | |
| 660 | + | s.ClearDamage() | |
| 661 | + | ||
| 662 | + | out, err := r.Draw(s.Frame, nil) | |
| 663 | + | if err != nil { | |
| 664 | + | t.Fatal(err) | |
| 665 | + | } | |
| 666 | + | if len(out) != 0 { | |
| 667 | + | t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out)) | |
| 668 | + | } | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | func TestDCS_STTerminatedIgnored(t *testing.T) { | |
| 672 | + | s := NewScreen(20, 2) | |
| 673 | + | s.Write([]byte("\x1bP+q696e646e\x1b\\prompt")) | |
| 674 | + | ||
| 675 | + | assertCell(t, s, 0, 0, 'p') | |
| 676 | + | assertCell(t, s, 1, 0, 'r') | |
| 677 | + | assertCell(t, s, 2, 0, 'o') | |
| 678 | + | assertCell(t, s, 3, 0, 'm') | |
| 679 | + | assertCell(t, s, 4, 0, 'p') | |
| 680 | + | assertCell(t, s, 5, 0, 't') | |
| 681 | + | if s.Col != 6 || s.Row != 0 { | |
| 682 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col) | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | func TestFishLikePromptRedrawPreservesTypedCharacters(t *testing.T) { | |
| 687 | + | s := NewScreen(10, 2) | |
| 688 | + | s.Write([]byte("> ")) | |
| 689 | + | s.Write([]byte("a\r\x1b[3C")) | |
| 690 | + | s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C")) | |
| 691 | + | ||
| 692 | + | assertCell(t, s, 0, 0, '>') | |
| 693 | + | assertCell(t, s, 1, 0, ' ') | |
| 694 | + | assertCell(t, s, 2, 0, 'a') | |
| 695 | + | assertCell(t, s, 3, 0, 'b') | |
| 696 | + | if s.Row != 0 || s.Col != 4 { | |
| 697 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col) | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | // --------------------------------------------------------------------------- | |
| 702 | + | // Scroll damage | |
| 703 | + | // --------------------------------------------------------------------------- | |
| 704 | + | ||
| 705 | + | func TestScrollDamageKind(t *testing.T) { | |
| 706 | + | s := NewScreen(5, 2) | |
| 707 | + | // Fill screen then cause a scroll. | |
| 708 | + | s.Write([]byte("AAAAABBBBB")) // both rows filled | |
| 709 | + | s.ClearDamage() | |
| 710 | + | ||
| 711 | + | s.Write([]byte("CCCCC")) // forces a scroll up | |
| 712 | + | ||
| 713 | + | d := s.Damage() | |
| 714 | + | if len(d) == 0 { | |
| 715 | + | t.Fatal("expected damage after scroll") | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | if !hasDamageKind(d, renderer.DamageScrollUp) { | |
| 719 | + | t.Errorf("expected DamageScrollUp in %v", damageKinds(d)) | |
| 720 | + | } | |
| 721 | + | if !hasDamageKind(d, renderer.DamageText) { | |
| 722 | + | t.Errorf("expected DamageText in %v", damageKinds(d)) | |
| 723 | + | } | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | func TestScrollDamageCoordinates(t *testing.T) { | |
| 727 | + | s := NewScreen(5, 3) | |
| 728 | + | s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows | |
| 729 | + | s.ClearDamage() | |
| 730 | + | ||
| 731 | + | // Write another char to force scroll. | |
| 732 | + | s.Write([]byte("D")) | |
| 733 | + | ||
| 734 | + | d := s.Damage() | |
| 735 | + | var scrollDamage *renderer.Damage | |
| 736 | + | for i, dd := range d { | |
| 737 | + | if dd.Kind == renderer.DamageScrollUp { | |
| 738 | + | scrollDamage = &d[i] | |
| 739 | + | break | |
| 740 | + | } | |
| 741 | + | } | |
| 742 | + | if scrollDamage == nil { | |
| 743 | + | t.Fatal("expected scroll damage") | |
| 744 | + | } | |
| 745 | + | if scrollDamage.X != 0 || scrollDamage.Y != 0 { | |
| 746 | + | t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y) | |
| 747 | + | } | |
| 748 | + | if scrollDamage.Width != 5 || scrollDamage.Height != 3 { | |
| 749 | + | t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height) | |
| 750 | + | } | |
| 751 | + | if scrollDamage.Count != 1 { | |
| 752 | + | t.Errorf("scroll count = %d, want 1", scrollDamage.Count) | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // --------------------------------------------------------------------------- | |
| 757 | + | // Damage coalescing | |
| 758 | + | // --------------------------------------------------------------------------- | |
| 759 | + | ||
| 760 | + | func TestDamageCoalescing(t *testing.T) { | |
| 761 | + | s := NewScreen(20, 3) | |
| 762 | + | s.Write([]byte("ABC")) // Three adjacent chars on one line | |
| 763 | + | ||
| 764 | + | d := s.Damage() | |
| 765 | + | // Should be coalesced into a single DamageText. | |
| 766 | + | if len(d) != 1 { | |
| 767 | + | t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d) | |
| 768 | + | } | |
| 769 | + | if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 { | |
| 770 | + | t.Errorf("unexpected coalesced damage: %+v", d[0]) | |
| 771 | + | } | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | func TestDamageCoalescingNewlineBreaks(t *testing.T) { | |
| 775 | + | s := NewScreen(10, 3) | |
| 776 | + | s.Write([]byte("AB\nCD")) | |
| 777 | + | ||
| 778 | + | d := s.Damage() | |
| 779 | + | // "AB" on line 0, "CD" on line 1 → two separate DamageText items. | |
| 780 | + | textCount := 0 | |
| 781 | + | for _, dd := range d { | |
| 782 | + | if dd.Kind == renderer.DamageText { | |
| 783 | + | textCount++ | |
| 784 | + | } | |
| 785 | + | } | |
| 786 | + | if textCount < 2 { | |
| 787 | + | t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount) | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | func TestDamageCoalescingFullRedraw(t *testing.T) { | |
| 792 | + | s := NewScreen(5, 3) | |
| 793 | + | // NewScreen already has FullRedraw in damage. | |
| 794 | + | d := s.Damage() | |
| 795 | + | if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw { | |
| 796 | + | t.Fatalf("expected single FullRedraw, got %+v", d) | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | // Writing should replace it with text damage. | |
| 800 | + | s.Write([]byte("X")) | |
| 801 | + | d = s.Damage() | |
| 802 | + | if len(d) == 0 { | |
| 803 | + | t.Fatal("expected damage after write") | |
| 804 | + | } | |
| 805 | + | if d[0].Kind == renderer.DamageFullRedraw { | |
| 806 | + | t.Error("FullRedraw should have been replaced by text damage") | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | // --------------------------------------------------------------------------- | |
| 811 | + | // Resize | |
| 812 | + | // --------------------------------------------------------------------------- | |
| 813 | + | ||
| 814 | + | func TestResizeLarger(t *testing.T) { | |
| 815 | + | s := NewScreen(5, 3) | |
| 816 | + | s.Write([]byte("Hello")) | |
| 817 | + | s.Resize(10, 5) | |
| 818 | + | ||
| 819 | + | if s.Frame.Width != 10 || s.Frame.Height != 5 { | |
| 820 | + | t.Errorf("frame size = %dx%d, want 10x5", s.Frame.Width, s.Frame.Height) | |
| 821 | + | } | |
| 822 | + | // Should be blank (new frame). | |
| 823 | + | for x := 0; x < 10; x++ { | |
| 824 | + | assertCell(t, s, x, 0, ' ') | |
| 825 | + | } | |
| 826 | + | // Cursor reset. | |
| 827 | + | if s.Row != 0 || s.Col != 0 { | |
| 828 | + | t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col) | |
| 829 | + | } | |
| 830 | + | // Should have FullRedraw. | |
| 831 | + | d := s.Damage() | |
| 832 | + | if !hasDamageKind(d, renderer.DamageFullRedraw) { | |
| 833 | + | t.Errorf("expected FullRedraw after resize, got %v", damageKinds(d)) | |
| 834 | + | } | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | func TestResizeSameSize(t *testing.T) { | |
| 838 | + | s := NewScreen(5, 3) | |
| 839 | + | s.Write([]byte("Hello")) | |
| 840 | + | ||
| 841 | + | s.Resize(5, 3) // same size | |
| 842 | + | ||
| 843 | + | assertCell(t, s, 0, 0, 'H') // content preserved | |
| 844 | + | // No FullRedraw. | |
| 845 | + | d := s.Damage() | |
| 846 | + | if len(d) != 0 && d[0].Kind == renderer.DamageFullRedraw { | |
| 847 | + | t.Error("same-size resize should not produce FullRedraw") | |
| 848 | + | } | |
| 849 | + | } | |
| 850 | + | ||
| 851 | + | // --------------------------------------------------------------------------- | |
| 852 | + | // ClearDamage | |
| 853 | + | // --------------------------------------------------------------------------- | |
| 854 | + | ||
| 855 | + | func TestClearDamage(t *testing.T) { | |
| 856 | + | s := NewScreen(5, 3) | |
| 857 | + | s.Write([]byte("Hello")) | |
| 858 | + | s.ClearDamage() | |
| 859 | + | d := s.Damage() | |
| 860 | + | if len(d) != 0 { | |
| 861 | + | t.Fatalf("expected empty damage after ClearDamage, got %+v", d) | |
| 862 | + | } | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | // --------------------------------------------------------------------------- | |
| 866 | + | // Non-printable / invalid bytes | |
| 867 | + | // --------------------------------------------------------------------------- | |
| 868 | + | ||
| 869 | + | func TestInvalidBytes(t *testing.T) { | |
| 870 | + | s := NewScreen(5, 3) | |
| 871 | + | // Invalid UTF-8 continuation byte without a start byte. | |
| 872 | + | s.Write([]byte{0x80, 0x81}) | |
| 873 | + | // Should not panic, and no cells should be modified. | |
| 874 | + | for y := 0; y < 3; y++ { | |
| 875 | + | for x := 0; x < 5; x++ { | |
| 876 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |
| 877 | + | t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune) | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | } | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | func TestControlChars(t *testing.T) { | |
| 884 | + | s := NewScreen(10, 3) | |
| 885 | + | // Control characters below 0x20 that are not handled (CR/LF/BS/TAB) should be ignored. | |
| 886 | + | ctrl := []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f} | |
| 887 | + | s.Write(ctrl) | |
| 888 | + | // No cells should be modified. | |
| 889 | + | for y := 0; y < 3; y++ { | |
| 890 | + | for x := 0; x < 10; x++ { | |
| 891 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |
| 892 | + | t.Errorf("cell(%d,%d) = %q, want space after control chars", x, y, c.Rune) | |
| 893 | + | } | |
| 894 | + | } | |
| 895 | + | } | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | // --------------------------------------------------------------------------- | |
| 899 | + | // Multiple sequential CSI sequences | |
| 900 | + | // --------------------------------------------------------------------------- | |
| 901 | + | ||
| 902 | + | func TestMultipleSGR(t *testing.T) { | |
| 903 | + | s := NewScreen(10, 3) | |
| 904 | + | s.Write([]byte("\x1b[31m\x1b[1m\x1b[44mX")) | |
| 905 | + | ||
| 906 | + | c := cellAt(s, 0, 0) | |
| 907 | + | if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 { | |
| 908 | + | t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style) | |
| 909 | + | } | |
| 910 | + | } | |
| 911 | + | ||
| 912 | + | func TestSGREmptyParamsReset(t *testing.T) { | |
| 913 | + | s := NewScreen(10, 3) | |
| 914 | + | s.Write([]byte("\x1b[31m\x1b[1m\x1b[m")) // empty params → reset | |
| 915 | + | ||
| 916 | + | if s.Style != (renderer.DefaultStyle()) { | |
| 917 | + | t.Errorf("style after empty SGR = %+v, want default", s.Style) | |
| 918 | + | } | |
| 919 | + | } | |
| 920 | + | ||
| 921 | + | // --------------------------------------------------------------------------- | |
| 922 | + | // Cursor move with zero / missing params | |
| 923 | + | // --------------------------------------------------------------------------- | |
| 924 | + | ||
| 925 | + | func TestCursorMoveZeroParams(t *testing.T) { | |
| 926 | + | s := NewScreen(10, 5) | |
| 927 | + | s.Write([]byte("\x1b[H")) // no params → (1,1) | |
| 928 | + | ||
| 929 | + | if s.Row != 0 || s.Col != 0 { | |
| 930 | + | t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col) | |
| 931 | + | } | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | func TestCursorMovePartialParams(t *testing.T) { | |
| 935 | + | s := NewScreen(10, 5) | |
| 936 | + | s.Row, s.Col = 3, 4 | |
| 937 | + | s.Write([]byte("\x1b[;5H")) // row=1 (default), col=5 | |
| 938 | + | ||
| 939 | + | if s.Row != 0 || s.Col != 4 { | |
| 940 | + | t.Errorf("cursor at (%d,%d), want (0,4)", s.Row, s.Col) | |
| 941 | + | } | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | // --------------------------------------------------------------------------- | |
| 945 | + | // No overflow on edge writes | |
| 946 | + | // --------------------------------------------------------------------------- | |
| 947 | + | ||
| 948 | + | func TestWriteAtEdgeNoPanic(t *testing.T) { | |
| 949 | + | s := NewScreen(1, 1) | |
| 950 | + | // Fill the only cell then try to write more (should scroll). | |
| 951 | + | s.Write([]byte("ABC")) | |
| 952 | + | // Should not panic. | |
| 953 | + | _ = cellAt(s, 0, 0) | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | // --------------------------------------------------------------------------- | |
| 957 | + | // Style persistence across writes | |
| 958 | + | // --------------------------------------------------------------------------- | |
| 959 | + | ||
| 960 | + | func TestStylePersists(t *testing.T) { | |
| 961 | + | s := NewScreen(10, 3) | |
| 962 | + | s.Write([]byte("\x1b[1mAB")) | |
| 963 | + | s.Write([]byte("CD")) | |
| 964 | + | ||
| 965 | + | c1 := cellAt(s, 0, 0) | |
| 966 | + | c2 := cellAt(s, 2, 0) | |
| 967 | + | if !c1.Style.Bold || !c2.Style.Bold { | |
| 968 | + | t.Error("bold should persist across Write calls") | |
| 969 | + | } | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | // --------------------------------------------------------------------------- | |
| 973 | + | // Rune width (CJK, emoji, combining marks, zero-width) | |
| 974 | + | // --------------------------------------------------------------------------- | |
| 975 | + | ||
| 976 | + | func TestCJKWideChars(t *testing.T) { | |
| 977 | + | s := NewScreen(10, 3) | |
| 978 | + | // Write two CJK characters that are width 2 in a real terminal. | |
| 979 | + | // They should be rendered as '?' placeholders, advancing cursor by 1 each. | |
| 980 | + | s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd")) // 你好 | |
| 981 | + | ||
| 982 | + | assertCell(t, s, 0, 0, '?') | |
| 983 | + | assertCell(t, s, 1, 0, '?') | |
| 984 | + | if s.Col != 2 || s.Row != 0 { | |
| 985 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |
| 986 | + | } | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | func TestCJKMixedWithASCII(t *testing.T) { | |
| 990 | + | s := NewScreen(10, 3) | |
| 991 | + | s.Write([]byte("A\xe4\xbd\xa0B")) // A你B | |
| 992 | + | ||
| 993 | + | assertCell(t, s, 0, 0, 'A') | |
| 994 | + | assertCell(t, s, 1, 0, '?') | |
| 995 | + | assertCell(t, s, 2, 0, 'B') | |
| 996 | + | if s.Col != 3 { | |
| 997 | + | t.Errorf("cursor at col=%d, want 3", s.Col) | |
| 998 | + | } | |
| 999 | + | } | |
| 1000 | + | ||
| 1001 | + | func TestEmojiRenderedAsPlaceholder(t *testing.T) { | |
| 1002 | + | s := NewScreen(10, 3) | |
| 1003 | + | em := []byte("\U0001f600") // 😀 grinning face | |
| 1004 | + | s.Write(em) | |
| 1005 | + | ||
| 1006 | + | // Wide emoji is rendered as '?' placeholder. | |
| 1007 | + | assertCell(t, s, 0, 0, '?') | |
| 1008 | + | if s.Col != 1 { | |
| 1009 | + | t.Errorf("cursor at col=%d, want 1", s.Col) | |
| 1010 | + | } | |
| 1011 | + | } | |
| 1012 | + | ||
| 1013 | + | func TestCombiningMarkSkipped(t *testing.T) { | |
| 1014 | + | s := NewScreen(10, 3) | |
| 1015 | + | // 'A' followed by combining acute accent U+0301 | |
| 1016 | + | s.Write([]byte("A\xcc\x81")) | |
| 1017 | + | ||
| 1018 | + | assertCell(t, s, 0, 0, 'A') | |
| 1019 | + | // Combining mark should be skipped — no cell written, no cursor advance. | |
| 1020 | + | if s.Col != 1 { | |
| 1021 | + | t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col) | |
| 1022 | + | } | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | func TestZeroWidthCharsSkipped(t *testing.T) { | |
| 1026 | + | s := NewScreen(10, 3) | |
| 1027 | + | // Zero-width space U+200B | |
| 1028 | + | s.Write([]byte("A\xe2\x80\x8bB")) | |
| 1029 | + | ||
| 1030 | + | assertCell(t, s, 0, 0, 'A') | |
| 1031 | + | assertCell(t, s, 1, 0, 'B') | |
| 1032 | + | if s.Col != 2 { | |
| 1033 | + | t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col) | |
| 1034 | + | } | |
| 1035 | + | } | |
| 1036 | + | ||
| 1037 | + | func TestRuneWidth(t *testing.T) { | |
| 1038 | + | tests := []struct { | |
| 1039 | + | r rune | |
| 1040 | + | width int | |
| 1041 | + | }{ | |
| 1042 | + | {' ', 1}, | |
| 1043 | + | {'a', 1}, | |
| 1044 | + | {'A', 1}, | |
| 1045 | + | {'1', 1}, | |
| 1046 | + | {0x00AD, 0}, // soft hyphen | |
| 1047 | + | {0x034F, 0}, // combining grapheme joiner | |
| 1048 | + | {0x0301, 0}, // combining acute accent | |
| 1049 | + | {0x0300, 0}, // combining grave accent | |
| 1050 | + | {0x200B, 0}, // zero-width space | |
| 1051 | + | {0x200C, 0}, // zero-width non-joiner | |
| 1052 | + | {0x200D, 0}, // zero-width joiner | |
| 1053 | + | {0xFEFF, 0}, // BOM / zero-width no-break space | |
| 1054 | + | {0x4E00, 2}, // CJK Unified Ideograph (一) | |
| 1055 | + | {0x4E8C, 2}, // CJK (二) | |
| 1056 | + | {0x9FFF, 2}, // CJK end of BMP | |
| 1057 | + | {0x3400, 2}, // CJK Extension A | |
| 1058 | + | {0xAC00, 2}, // Hangul Syllable (가) | |
| 1059 | + | {0xD7AF, 2}, // Hangul Syllable end | |
| 1060 | + | {0x3000, 2}, // CJK Ideographic Space | |
| 1061 | + | {0xFF01, 2}, // Fullwidth Exclamation Mark | |
| 1062 | + | {0xFFE0, 2}, // Fullwidth Cent Sign | |
| 1063 | + | {0x1F600, 2}, // 😀 | |
| 1064 | + | {0x1F680, 2}, // 🚀 | |
| 1065 | + | {'\x00', 0}, | |
| 1066 | + | {'\x01', 0}, | |
| 1067 | + | {'\x1b', 0}, | |
| 1068 | + | {0x7F, 0}, | |
| 1069 | + | } | |
| 1070 | + | ||
| 1071 | + | for _, tt := range tests { | |
| 1072 | + | got := runeWidth(tt.r) | |
| 1073 | + | if got != tt.width { | |
| 1074 | + | t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) | |
| 1075 | + | } | |
| 1076 | + | } | |
| 1077 | + | } | |
| 1078 | + | ||
| 1079 | + | func TestDamageReturnsReference(t *testing.T) { | |
| 1080 | + | // Damage() should return a reference to internal slice (no copy). | |
| 1081 | + | s := NewScreen(10, 3) | |
| 1082 | + | s.Write([]byte("X")) | |
| 1083 | + | d1 := s.Damage() | |
| 1084 | + | // Internal slice should be the same (or at least reference the same backing). | |
| 1085 | + | if len(d1) == 0 { | |
| 1086 | + | t.Fatal("expected damage") | |
| 1087 | + | } | |
| 1088 | + | // Write more to trigger record. | |
| 1089 | + | s.Write([]byte("Y")) | |
| 1090 | + | d2 := s.Damage() | |
| 1091 | + | _ = d2 | |
| 1092 | + | // Both d1 and d2 reference the internal slice; we just don't crash. | |
| 1093 | + | } | |
| 1094 | + | ||
| 1095 | + | func TestCJKSurroundedByASCII(t *testing.T) { | |
| 1096 | + | s := NewScreen(10, 3) | |
| 1097 | + | s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc")) // a你b好c | |
| 1098 | + | ||
| 1099 | + | assertCell(t, s, 0, 0, 'a') | |
| 1100 | + | assertCell(t, s, 1, 0, '?') | |
| 1101 | + | assertCell(t, s, 2, 0, 'b') | |
| 1102 | + | assertCell(t, s, 3, 0, '?') | |
| 1103 | + | assertCell(t, s, 4, 0, 'c') | |
| 1104 | + | if s.Col != 5 { | |
| 1105 | + | t.Errorf("cursor at col=%d, want 5", s.Col) | |
| 1106 | + | } | |
| 1107 | + | } | |
| 1108 | + | ||
| 1109 | + | func TestCJKAtEdgeWraps(t *testing.T) { | |
| 1110 | + | s := NewScreen(2, 1) | |
| 1111 | + | // Write two ASCII chars to fill the line. | |
| 1112 | + | s.Write([]byte("AB")) // Cells = [A B], Col=2 | |
| 1113 | + | // CJK at col 2 (edge) triggers newline → scroll on 1-row screen. | |
| 1114 | + | // scrollUp blanks the only row, then '?' is written at col 0. | |
| 1115 | + | s.Write([]byte("\xe4\xbd\xa0")) // 你 | |
| 1116 | + | ||
| 1117 | + | assertCell(t, s, 0, 0, '?') // CJK placeholder after scroll+blank | |
| 1118 | + | assertCell(t, s, 1, 0, ' ') // untouched | |
| 1119 | + | if s.Col != 1 || s.Row != 0 { | |
| 1120 | + | t.Errorf("cursor at col=%d row=%d, want col=1 row=0", s.Col, s.Row) | |
| 1121 | + | } | |
| 1122 | + | } | |
| 1123 | + | ||
| 1124 | + | func TestCJKDamageWidthNormalized(t *testing.T) { | |
| 1125 | + | s := NewScreen(10, 3) | |
| 1126 | + | s.ClearDamage() | |
| 1127 | + | s.Write([]byte("\xe4\xbd\xa0")) // 你 | |
| 1128 | + | ||
| 1129 | + | d := s.Damage() | |
| 1130 | + | if len(d) == 0 { | |
| 1131 | + | t.Fatal("expected damage") | |
| 1132 | + | } | |
| 1133 | + | // After normalization, damage width should be 1, not 2. | |
| 1134 | + | if d[0].Width != 1 { | |
| 1135 | + | t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width) | |
| 1136 | + | } | |
| 1137 | + | } | |
| 1138 | + | ||
| 1139 | + | func TestEmojiDamageWidthNormalized(t *testing.T) { | |
| 1140 | + | s := NewScreen(10, 3) | |
| 1141 | + | s.ClearDamage() | |
| 1142 | + | s.Write([]byte("\U0001f600")) // 😀 | |
| 1143 | + | ||
| 1144 | + | d := s.Damage() | |
| 1145 | + | if len(d) == 0 { | |
| 1146 | + | t.Fatal("expected damage") | |
| 1147 | + | } | |
| 1148 | + | if d[0].Width != 1 { | |
| 1149 | + | t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width) | |
| 1150 | + | } | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | func TestMixedCursorAlignment(t *testing.T) { | |
| 1154 | + | s := NewScreen(10, 3) | |
| 1155 | + | // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́' | |
| 1156 | + | s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81")) | |
| 1157 | + | ||
| 1158 | + | assertCell(t, s, 0, 0, 'a') // 'a' | |
| 1159 | + | assertCell(t, s, 1, 0, '?') // 你 normalized | |
| 1160 | + | assertCell(t, s, 2, 0, '?') // 😀 normalized | |
| 1161 | + | assertCell(t, s, 3, 0, 'b') // 'b' | |
| 1162 | + | // Combining mark skipped, no cell at col 4. | |
| 1163 | + | // Cursor at col 4 (advanced by a, CJK, emoji, b). | |
| 1164 | + | if s.Col != 4 || s.Row != 0 { | |
| 1165 | + | t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row) | |
| 1166 | + | } | |
| 1167 | + | } | |
| 1168 | + | ||
| 1169 | + | func TestCombiningMarkNoCellWritten(t *testing.T) { | |
| 1170 | + | s := NewScreen(10, 3) | |
| 1171 | + | s.ClearDamage() | |
| 1172 | + | s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char) | |
| 1173 | + | ||
| 1174 | + | // No cells should be modified. | |
| 1175 | + | for y := 0; y < 3; y++ { | |
| 1176 | + | for x := 0; x < 10; x++ { | |
| 1177 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |
| 1178 | + | t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune) | |
| 1179 | + | } | |
| 1180 | + | } | |
| 1181 | + | } | |
| 1182 | + | if s.Col != 0 || s.Row != 0 { | |
| 1183 | + | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |
| 1184 | + | } | |
| 1185 | + | d := s.Damage() | |
| 1186 | + | if len(d) != 0 { | |
| 1187 | + | t.Errorf("expected no damage for combining mark alone, got %+v", d) | |
| 1188 | + | } | |
| 1189 | + | } | |
| 1190 | + | ||
| 1191 | + | // --------------------------------------------------------------------------- | |
| 1192 | + | // CSI S / SU — Scroll Up | |
| 1193 | + | // --------------------------------------------------------------------------- | |
| 1194 | + | ||
| 1195 | + | func TestCSI_SCrollUpDefault(t *testing.T) { | |
| 1196 | + | s := NewScreen(5, 4) | |
| 1197 | + | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |
| 1198 | + | s.ClearDamage() | |
| 1199 | + | ||
| 1200 | + | // CSI S with no param → scroll up by 1. | |
| 1201 | + | s.Write([]byte("\x1b[S")) | |
| 1202 | + | ||
| 1203 | + | // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank. | |
| 1204 | + | assertCell(t, s, 0, 0, 'B') | |
| 1205 | + | assertCell(t, s, 0, 1, 'C') | |
| 1206 | + | assertCell(t, s, 0, 2, 'D') | |
| 1207 | + | assertCell(t, s, 0, 3, ' ') | |
| 1208 | + | ||
| 1209 | + | // Cursor should NOT have moved. | |
| 1210 | + | if s.Col != 5 || s.Row != 3 { | |
| 1211 | + | t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row) | |
| 1212 | + | } | |
| 1213 | + | ||
| 1214 | + | d := s.Damage() | |
| 1215 | + | if !hasDamageKind(d, renderer.DamageScrollUp) { | |
| 1216 | + | t.Errorf("expected DamageScrollUp, got %v", damageKinds(d)) | |
| 1217 | + | } | |
| 1218 | + | } | |
| 1219 | + | ||
| 1220 | + | func TestCSI_SCrollUpWithCount(t *testing.T) { | |
| 1221 | + | s := NewScreen(5, 4) | |
| 1222 | + | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |
| 1223 | + | s.ClearDamage() | |
| 1224 | + | ||
| 1225 | + | // CSI 2 S → scroll up by 2. | |
| 1226 | + | s.Write([]byte("\x1b[2S")) | |
| 1227 | + | ||
| 1228 | + | // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank. | |
| 1229 | + | assertCell(t, s, 0, 0, 'C') | |
| 1230 | + | assertCell(t, s, 0, 1, 'D') | |
| 1231 | + | assertCell(t, s, 0, 2, ' ') | |
| 1232 | + | assertCell(t, s, 0, 3, ' ') | |
| 1233 | + | ||
| 1234 | + | // Cursor preserved. | |
| 1235 | + | if s.Col != 5 || s.Row != 3 { | |
| 1236 | + | t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row) | |
| 1237 | + | } | |
| 1238 | + | } | |
| 1239 | + | ||
| 1240 | + | func TestCSI_SCrollUpClamp(t *testing.T) { | |
| 1241 | + | s := NewScreen(5, 3) | |
| 1242 | + | s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows | |
| 1243 | + | s.ClearDamage() | |
| 1244 | + | ||
| 1245 | + | // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank). | |
| 1246 | + | s.Write([]byte("\x1b[100S")) | |
| 1247 | + | ||
| 1248 | + | // All rows should be blank. | |
| 1249 | + | for y := 0; y < 3; y++ { | |
| 1250 | + | for x := 0; x < 5; x++ { | |
| 1251 | + | assertCell(t, s, x, y, ' ') | |
| 1252 | + | } | |
| 1253 | + | } | |
| 1254 | + | } | |
| 1255 | + | ||
| 1256 | + | func TestCSI_SCrollUpCountZero(t *testing.T) { | |
| 1257 | + | s := NewScreen(5, 3) | |
| 1258 | + | s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows | |
| 1259 | + | s.ClearDamage() | |
| 1260 | + | ||
| 1261 | + | // CSI 0 S → default to 1 (parameter 0 means default in VT spec). | |
| 1262 | + | s.Write([]byte("\x1b[0S")) | |
| 1263 | + | ||
| 1264 | + | // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank. | |
| 1265 | + | assertCell(t, s, 0, 0, 'B') | |
| 1266 | + | assertCell(t, s, 0, 1, 'C') | |
| 1267 | + | assertCell(t, s, 0, 2, ' ') | |
| 1268 | + | } | |
| 1269 | + | ||
| 1270 | + | func TestCSI_SCrollUpDamageCount(t *testing.T) { | |
| 1271 | + | s := NewScreen(5, 4) | |
| 1272 | + | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |
| 1273 | + | s.ClearDamage() | |
| 1274 | + | ||
| 1275 | + | s.Write([]byte("\x1b[3S")) // scroll up by 3 | |
| 1276 | + | ||
| 1277 | + | d := s.Damage() | |
| 1278 | + | var scrollDamage *renderer.Damage | |
| 1279 | + | for i, dd := range d { | |
| 1280 | + | if dd.Kind == renderer.DamageScrollUp { | |
| 1281 | + | scrollDamage = &d[i] | |
| 1282 | + | break | |
| 1283 | + | } | |
| 1284 | + | } | |
| 1285 | + | if scrollDamage == nil { | |
| 1286 | + | t.Fatal("expected DamageScrollUp") | |
| 1287 | + | } | |
| 1288 | + | if scrollDamage.Count != 3 { | |
| 1289 | + | t.Errorf("scroll count = %d, want 3", scrollDamage.Count) | |
| 1290 | + | } | |
| 1291 | + | if scrollDamage.Width != 5 || scrollDamage.Height != 4 { | |
| 1292 | + | t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height) | |
| 1293 | + | } | |
| 1294 | + | ||
| 1295 | + | // Should also have text damage for the blanked rows (bottom 3 rows). | |
| 1296 | + | if !hasDamageKind(d, renderer.DamageText) { | |
| 1297 | + | t.Error("expected DamageText for blanked rows") | |
| 1298 | + | } | |
| 1299 | + | } | |
| 1300 | + | ||
| 1301 | + | func TestCSI_SCursorPreserved(t *testing.T) { | |
| 1302 | + | s := NewScreen(10, 5) | |
| 1303 | + | s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows | |
| 1304 | + | s.ClearDamage() | |
| 1305 | + | ||
| 1306 | + | // Move cursor to middle of row 2. | |
| 1307 | + | s.Write([]byte("\x1b[3;3H")) // row=3, col=3 | |
| 1308 | + | s.Write([]byte("\x1b[2S")) // scroll up by 2 | |
| 1309 | + | ||
| 1310 | + | // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2). | |
| 1311 | + | if s.Row != 2 || s.Col != 2 { | |
| 1312 | + | t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col) | |
| 1313 | + | } | |
| 1314 | + | } | |
| 1315 | + | ||
| 1316 | + | // --------------------------------------------------------------------------- | |
| 1317 | + | // OSC sequences | |
| 1318 | + | // --------------------------------------------------------------------------- | |
| 1319 | + | ||
| 1320 | + | func TestOSC_BELTerminatedIgnored(t *testing.T) { | |
| 1321 | + | s := NewScreen(20, 2) | |
| 1322 | + | s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ ")) | |
| 1323 | + | ||
| 1324 | + | assertCell(t, s, 0, 0, 'f') | |
| 1325 | + | assertCell(t, s, 1, 0, 'i') | |
| 1326 | + | assertCell(t, s, 2, 0, 's') | |
| 1327 | + | assertCell(t, s, 3, 0, 'h') | |
| 1328 | + | assertCell(t, s, 4, 0, '$') | |
| 1329 | + | assertCell(t, s, 5, 0, ' ') | |
| 1330 | + | if s.Col != 6 || s.Row != 0 { | |
| 1331 | + | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |
| 1332 | + | } | |
| 1333 | + | } | |
| 1334 | + | ||
| 1335 | + | func TestOSC_STTerminatedIgnored(t *testing.T) { | |
| 1336 | + | s := NewScreen(20, 2) | |
| 1337 | + | s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> ")) | |
| 1338 | + | ||
| 1339 | + | assertCell(t, s, 0, 0, '>') | |
| 1340 | + | assertCell(t, s, 1, 0, ' ') | |
| 1341 | + | if s.Col != 2 || s.Row != 0 { | |
| 1342 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |
| 1343 | + | } | |
| 1344 | + | } | |
| 1345 | + | ||
| 1346 | + | func TestOSC_MultipleSequencesIgnored(t *testing.T) { | |
| 1347 | + | s := NewScreen(20, 2) | |
| 1348 | + | s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt")) | |
| 1349 | + | ||
| 1350 | + | assertCell(t, s, 0, 0, 'p') | |
| 1351 | + | assertCell(t, s, 1, 0, 'r') | |
| 1352 | + | assertCell(t, s, 2, 0, 'o') | |
| 1353 | + | assertCell(t, s, 3, 0, 'm') | |
| 1354 | + | assertCell(t, s, 4, 0, 'p') | |
| 1355 | + | assertCell(t, s, 5, 0, 't') | |
| 1356 | + | if s.Col != 6 || s.Row != 0 { | |
| 1357 | + | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |
| 1358 | + | } | |
| 1359 | + | } | |
| 1360 | + | ||
| 1361 | + | func TestOSC_BELTerminatedSplitAcrossWritesIgnored(t *testing.T) { | |
| 1362 | + | s := NewScreen(20, 2) | |
| 1363 | + | s.Write([]byte("\x1b]0;~/Projects/ymux")) | |
| 1364 | + | s.Write([]byte(" - fish\x07fish$ ")) | |
| 1365 | + | ||
| 1366 | + | assertCell(t, s, 0, 0, 'f') | |
| 1367 | + | assertCell(t, s, 1, 0, 'i') | |
| 1368 | + | assertCell(t, s, 2, 0, 's') | |
| 1369 | + | assertCell(t, s, 3, 0, 'h') | |
| 1370 | + | assertCell(t, s, 4, 0, '$') | |
| 1371 | + | assertCell(t, s, 5, 0, ' ') | |
| 1372 | + | if s.Col != 6 || s.Row != 0 { | |
| 1373 | + | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |
| 1374 | + | } | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | func TestOSC_STTerminatedSplitAcrossWritesIgnored(t *testing.T) { | |
| 1378 | + | s := NewScreen(20, 2) | |
| 1379 | + | s.Write([]byte("\x1b]133;A;click_events=1\x1b")) | |
| 1380 | + | s.Write([]byte("\\> ")) | |
| 1381 | + | ||
| 1382 | + | assertCell(t, s, 0, 0, '>') | |
| 1383 | + | assertCell(t, s, 1, 0, ' ') | |
| 1384 | + | if s.Col != 2 || s.Row != 0 { | |
| 1385 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |
| 1386 | + | } | |
| 1387 | + | } | |
| 1388 | + | ||
| 1389 | + | func TestOSC_UnterminatedSequenceIsBoundedAndDropped(t *testing.T) { | |
| 1390 | + | s := NewScreen(20, 2) | |
| 1391 | + | payload := strings.Repeat("x", maxEscapeBufferLen+1) | |
| 1392 | + | s.Write([]byte("\x1b]0;" + payload)) | |
| 1393 | + | s.Write([]byte("OK")) | |
| 1394 | + | ||
| 1395 | + | assertCell(t, s, 0, 0, 'O') | |
| 1396 | + | assertCell(t, s, 1, 0, 'K') | |
| 1397 | + | if len(s.escapeBuf) != 0 { | |
| 1398 | + | t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf)) | |
| 1399 | + | } | |
| 1400 | + | } | |
| 1401 | + | ||
| 1402 | + | // --------------------------------------------------------------------------- | |
| 1403 | + | // C1 Unicode controls (U+0080-U+009F) | |
| 1404 | + | // --------------------------------------------------------------------------- | |
| 1405 | + | ||
| 1406 | + | func TestC1ControlsDropped(t *testing.T) { | |
| 1407 | + | s := NewScreen(10, 3) | |
| 1408 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 PAD | |
| 1409 | + | s.Write([]byte{0xC2, 0x9F}) // U+009F APC | |
| 1410 | + | ||
| 1411 | + | // No cells should be modified. | |
| 1412 | + | for y := 0; y < 3; y++ { | |
| 1413 | + | for x := 0; x < 10; x++ { | |
| 1414 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |
| 1415 | + | t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune) | |
| 1416 | + | } | |
| 1417 | + | } | |
| 1418 | + | } | |
| 1419 | + | // Cursor should not have moved. | |
| 1420 | + | if s.Col != 0 || s.Row != 0 { | |
| 1421 | + | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |
| 1422 | + | } | |
| 1423 | + | } | |
| 1424 | + | ||
| 1425 | + | func TestC1ControlsRange(t *testing.T) { | |
| 1426 | + | s := NewScreen(10, 3) | |
| 1427 | + | // Send all C1 control runes U+0080 through U+009F. | |
| 1428 | + | for r := 0x80; r <= 0x9F; r++ { | |
| 1429 | + | s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)}) | |
| 1430 | + | } | |
| 1431 | + | ||
| 1432 | + | // No cells should be modified. | |
| 1433 | + | for y := 0; y < 3; y++ { | |
| 1434 | + | for x := 0; x < 10; x++ { | |
| 1435 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |
| 1436 | + | t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune) | |
| 1437 | + | } | |
| 1438 | + | } | |
| 1439 | + | } | |
| 1440 | + | if s.Col != 0 || s.Row != 0 { | |
| 1441 | + | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |
| 1442 | + | } | |
| 1443 | + | } | |
| 1444 | + | ||
| 1445 | + | func TestC1ControlsAfterPrintable(t *testing.T) { | |
| 1446 | + | s := NewScreen(10, 3) | |
| 1447 | + | // Printable characters should still work after C1 controls. | |
| 1448 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |
| 1449 | + | s.Write([]byte("AB")) | |
| 1450 | + | s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) | |
| 1451 | + | s.Write([]byte("CD")) | |
| 1452 | + | ||
| 1453 | + | assertCell(t, s, 0, 0, 'A') | |
| 1454 | + | assertCell(t, s, 1, 0, 'B') | |
| 1455 | + | assertCell(t, s, 2, 0, 'C') | |
| 1456 | + | assertCell(t, s, 3, 0, 'D') | |
| 1457 | + | if s.Col != 4 { | |
| 1458 | + | t.Errorf("cursor at col=%d, want 4", s.Col) | |
| 1459 | + | } | |
| 1460 | + | } | |
| 1461 | + | ||
| 1462 | + | func TestC1ControlsPreserveCRLF(t *testing.T) { | |
| 1463 | + | s := NewScreen(10, 3) | |
| 1464 | + | // C1 controls mixed with CR/LF should preserve CR/LF behavior. | |
| 1465 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |
| 1466 | + | s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A | |
| 1467 | + | s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) | |
| 1468 | + | ||
| 1469 | + | assertCell(t, s, 0, 0, 'B') | |
| 1470 | + | } | |
| 1471 | + | ||
| 1472 | + | func TestC1ControlsPreserveLF(t *testing.T) { | |
| 1473 | + | s := NewScreen(10, 3) | |
| 1474 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |
| 1475 | + | s.Write([]byte("A\nB")) | |
| 1476 | + | ||
| 1477 | + | assertCell(t, s, 0, 0, 'A') | |
| 1478 | + | assertCell(t, s, 1, 1, 'B') | |
| 1479 | + | if s.Row != 1 || s.Col != 2 { | |
| 1480 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col) | |
| 1481 | + | } | |
| 1482 | + | } | |
| 1483 | + | ||
| 1484 | + | func TestC1ControlsNoDamage(t *testing.T) { | |
| 1485 | + | s := NewScreen(10, 3) | |
| 1486 | + | s.ClearDamage() | |
| 1487 | + | // C1 controls should produce no damage. | |
| 1488 | + | s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F}) | |
| 1489 | + | ||
| 1490 | + | d := s.Damage() | |
| 1491 | + | if len(d) != 0 { | |
| 1492 | + | t.Errorf("expected no damage from C1 controls, got %+v", d) | |
| 1493 | + | } | |
| 1494 | + | } | |
| 1495 | + | ||
| 1496 | + | func TestEraseDisplayModes(t *testing.T) { | |
| 1497 | + | s := NewScreen(5, 3) | |
| 1498 | + | s.Write([]byte("abcdefghijklmno")) | |
| 1499 | + | s.Write([]byte("\x1b[2;3H")) | |
| 1500 | + | s.ClearDamage() | |
| 1501 | + | s.Write([]byte("\x1b[J")) | |
| 1502 | + | assertCell(t, s, 0, 0, 'a') | |
| 1503 | + | assertCell(t, s, 1, 1, 'g') | |
| 1504 | + | if c := cellAt(s, 2, 1); c.Rune != ' ' { | |
| 1505 | + | t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune) | |
| 1506 | + | } | |
| 1507 | + | ||
| 1508 | + | s = NewScreen(5, 3) | |
| 1509 | + | s.Write([]byte("abcdefghijklmno")) | |
| 1510 | + | s.Write([]byte("\x1b[2;3H")) | |
| 1511 | + | s.Write([]byte("\x1b[1J")) | |
| 1512 | + | if c := cellAt(s, 0, 0); c.Rune != ' ' { | |
| 1513 | + | t.Fatalf("start-to-cursor clear left %q at start", c.Rune) | |
| 1514 | + | } | |
| 1515 | + | assertCell(t, s, 3, 1, 'i') | |
| 1516 | + | } | |
| 1517 | + | ||
| 1518 | + | func TestEraseLineModes(t *testing.T) { | |
| 1519 | + | s := NewScreen(5, 2) | |
| 1520 | + | s.Write([]byte("abcde")) | |
| 1521 | + | s.Write([]byte("\x1b[1;3H")) | |
| 1522 | + | s.Write([]byte("\x1b[1K")) | |
| 1523 | + | if c := cellAt(s, 0, 0); c.Rune != ' ' { | |
| 1524 | + | t.Fatalf("line start clear left %q", c.Rune) | |
| 1525 | + | } | |
| 1526 | + | assertCell(t, s, 3, 0, 'd') | |
| 1527 | + | ||
| 1528 | + | s = NewScreen(5, 2) | |
| 1529 | + | s.Write([]byte("abcde")) | |
| 1530 | + | s.Write([]byte("\x1b[1;3H")) | |
| 1531 | + | s.Write([]byte("\x1b[2K")) | |
| 1532 | + | for x := 0; x < 5; x++ { | |
| 1533 | + | if c := cellAt(s, x, 0); c.Rune != ' ' { | |
| 1534 | + | t.Fatalf("line clear left %q at %d", c.Rune, x) | |
| 1535 | + | } | |
| 1536 | + | } | |
| 1537 | + | } | |
| @@ -0,0 +1,1537 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // --------------------------------------------------------------------------- | |||
| 11 | + | // Helpers | |||
| 12 | + | // --------------------------------------------------------------------------- | |||
| 13 | + | ||||
| 14 | + | func damageKinds(d []renderer.Damage) []renderer.DamageKind { | |||
| 15 | + | ks := make([]renderer.DamageKind, len(d)) | |||
| 16 | + | for i, dd := range d { | |||
| 17 | + | ks[i] = dd.Kind | |||
| 18 | + | } | |||
| 19 | + | return ks | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool { | |||
| 23 | + | for _, dd := range d { | |||
| 24 | + | if dd.Kind == kind { | |||
| 25 | + | return true | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | return false | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func cellAt(s *Screen, x, y int) renderer.Cell { | |||
| 32 | + | return s.Frame.At(x, y) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func assertCell(t *testing.T, s *Screen, x, y int, expected rune) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | c := cellAt(s, x, y) | |||
| 38 | + | if c.Rune != expected { | |||
| 39 | + | t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func lineText(s *Screen, y int) string { | |||
| 44 | + | out := make([]rune, s.Frame.Width) | |||
| 45 | + | for x := range s.Frame.Width { | |||
| 46 | + | out[x] = s.Frame.At(x, y).Rune | |||
| 47 | + | } | |||
| 48 | + | return string(out) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // --------------------------------------------------------------------------- | |||
| 52 | + | // Printable ASCII / UTF-8 | |||
| 53 | + | // --------------------------------------------------------------------------- | |||
| 54 | + | ||||
| 55 | + | func TestWritePrintable(t *testing.T) { | |||
| 56 | + | s := NewScreen(5, 3) | |||
| 57 | + | s.Write([]byte("Hi")) | |||
| 58 | + | ||||
| 59 | + | assertCell(t, s, 0, 0, 'H') | |||
| 60 | + | assertCell(t, s, 1, 0, 'i') | |||
| 61 | + | if s.Col != 2 || s.Row != 0 { | |||
| 62 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // Should have damage. | |||
| 66 | + | d := s.Damage() | |||
| 67 | + | if len(d) == 0 { | |||
| 68 | + | t.Fatal("expected damage after writing") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestWriteUTF8(t *testing.T) { | |||
| 73 | + | s := NewScreen(10, 3) | |||
| 74 | + | s.Write([]byte("aäéø©")) | |||
| 75 | + | ||||
| 76 | + | assertCell(t, s, 0, 0, 'a') | |||
| 77 | + | assertCell(t, s, 1, 0, 'ä') | |||
| 78 | + | assertCell(t, s, 2, 0, 'é') | |||
| 79 | + | assertCell(t, s, 3, 0, 'ø') | |||
| 80 | + | assertCell(t, s, 4, 0, '©') | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestWritePrintableBeyondWidthWraps(t *testing.T) { | |||
| 84 | + | s := NewScreen(4, 2) | |||
| 85 | + | s.Write([]byte("ABCDE")) | |||
| 86 | + | ||||
| 87 | + | assertCell(t, s, 0, 0, 'A') | |||
| 88 | + | assertCell(t, s, 1, 0, 'B') | |||
| 89 | + | assertCell(t, s, 2, 0, 'C') | |||
| 90 | + | assertCell(t, s, 3, 0, 'D') | |||
| 91 | + | // E wrapped to next line. | |||
| 92 | + | assertCell(t, s, 0, 1, 'E') | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestWritePrintableBeyondScreenScrolls(t *testing.T) { | |||
| 96 | + | s := NewScreen(3, 2) | |||
| 97 | + | // Fill both lines. | |||
| 98 | + | s.Write([]byte("ABC")) | |||
| 99 | + | s.Write([]byte("DEF")) | |||
| 100 | + | // Row is now 1 (bottom). One more char should scroll. | |||
| 101 | + | s.Write([]byte("G")) | |||
| 102 | + | ||||
| 103 | + | // After scroll: old row 0 = "DEF", new row 1 = "G ". | |||
| 104 | + | assertCell(t, s, 0, 0, 'D') | |||
| 105 | + | assertCell(t, s, 1, 0, 'E') | |||
| 106 | + | assertCell(t, s, 2, 0, 'F') | |||
| 107 | + | assertCell(t, s, 0, 1, 'G') | |||
| 108 | + | assertCell(t, s, 1, 1, ' ') | |||
| 109 | + | ||||
| 110 | + | // Check scroll damage. | |||
| 111 | + | d := s.Damage() | |||
| 112 | + | if !hasDamageKind(d, renderer.DamageScrollUp) { | |||
| 113 | + | t.Error("expected scroll damage") | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // --------------------------------------------------------------------------- | |||
| 118 | + | // Carriage Return | |||
| 119 | + | // --------------------------------------------------------------------------- | |||
| 120 | + | ||||
| 121 | + | func TestCR(t *testing.T) { | |||
| 122 | + | s := NewScreen(10, 3) | |||
| 123 | + | s.Write([]byte("Hello")) | |||
| 124 | + | // CR moves column back to 0. | |||
| 125 | + | s.Write([]byte("\rWorld")) | |||
| 126 | + | ||||
| 127 | + | assertCell(t, s, 0, 0, 'W') | |||
| 128 | + | assertCell(t, s, 1, 0, 'o') | |||
| 129 | + | assertCell(t, s, 2, 0, 'r') | |||
| 130 | + | assertCell(t, s, 3, 0, 'l') | |||
| 131 | + | assertCell(t, s, 4, 0, 'd') | |||
| 132 | + | if s.Col != 5 { | |||
| 133 | + | t.Errorf("col after CR + World = %d, want 5", s.Col) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // --------------------------------------------------------------------------- | |||
| 138 | + | // Line Feed | |||
| 139 | + | // --------------------------------------------------------------------------- | |||
| 140 | + | ||||
| 141 | + | func TestLF(t *testing.T) { | |||
| 142 | + | s := NewScreen(5, 3) | |||
| 143 | + | s.Write([]byte("A\r\nB\r\nC")) | |||
| 144 | + | ||||
| 145 | + | assertCell(t, s, 0, 0, 'A') | |||
| 146 | + | assertCell(t, s, 0, 1, 'B') | |||
| 147 | + | assertCell(t, s, 0, 2, 'C') | |||
| 148 | + | if s.Row != 2 || s.Col != 1 { | |||
| 149 | + | t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestLFDoesNotResetColumn(t *testing.T) { | |||
| 154 | + | s := NewScreen(5, 3) | |||
| 155 | + | s.Write([]byte("AB\nC")) | |||
| 156 | + | ||||
| 157 | + | assertCell(t, s, 0, 0, 'A') | |||
| 158 | + | assertCell(t, s, 1, 0, 'B') | |||
| 159 | + | assertCell(t, s, 0, 1, ' ') | |||
| 160 | + | assertCell(t, s, 2, 1, 'C') | |||
| 161 | + | if s.Row != 1 || s.Col != 3 { | |||
| 162 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestLFAtPendingWrapBoundaryAdvancesOneLine(t *testing.T) { | |||
| 167 | + | s := NewScreen(5, 4) | |||
| 168 | + | s.Write([]byte("ABCDE\nZ")) | |||
| 169 | + | ||||
| 170 | + | assertCell(t, s, 4, 0, 'E') | |||
| 171 | + | assertCell(t, s, 4, 1, 'Z') | |||
| 172 | + | if s.Row != 1 || s.Col != 5 { | |||
| 173 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func TestLFScroll(t *testing.T) { | |||
| 178 | + | s := NewScreen(5, 2) | |||
| 179 | + | // Fill both lines then CRLF to scroll and return to column 0. | |||
| 180 | + | s.Write([]byte("AAAAA")) | |||
| 181 | + | s.Write([]byte("BBBBB")) | |||
| 182 | + | s.Write([]byte("\r\nCCCCC")) | |||
| 183 | + | ||||
| 184 | + | assertCell(t, s, 0, 0, 'B') | |||
| 185 | + | assertCell(t, s, 4, 1, 'C') | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // --------------------------------------------------------------------------- | |||
| 189 | + | // Backspace | |||
| 190 | + | // --------------------------------------------------------------------------- | |||
| 191 | + | ||||
| 192 | + | func TestBS(t *testing.T) { | |||
| 193 | + | s := NewScreen(10, 3) | |||
| 194 | + | s.Write([]byte("Hello\b")) | |||
| 195 | + | ||||
| 196 | + | if s.Col != 4 { | |||
| 197 | + | t.Errorf("col after BS = %d, want 4", s.Col) | |||
| 198 | + | } | |||
| 199 | + | assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestBSAtColumnZero(t *testing.T) { | |||
| 203 | + | s := NewScreen(10, 3) | |||
| 204 | + | s.Write([]byte("\b")) // BS at col 0 should be no-op | |||
| 205 | + | ||||
| 206 | + | if s.Col != 0 { | |||
| 207 | + | t.Errorf("col = %d, want 0", s.Col) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // --------------------------------------------------------------------------- | |||
| 212 | + | // Tab | |||
| 213 | + | // --------------------------------------------------------------------------- | |||
| 214 | + | ||||
| 215 | + | func TestTab(t *testing.T) { | |||
| 216 | + | s := NewScreen(20, 3) | |||
| 217 | + | s.Write([]byte("A\tB")) | |||
| 218 | + | ||||
| 219 | + | assertCell(t, s, 0, 0, 'A') | |||
| 220 | + | // Tab advances to next 8-column boundary (col 8). | |||
| 221 | + | for i := 1; i < 8; i++ { | |||
| 222 | + | assertCell(t, s, i, 0, ' ') | |||
| 223 | + | } | |||
| 224 | + | assertCell(t, s, 8, 0, 'B') | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // --------------------------------------------------------------------------- | |||
| 228 | + | // CSI SGR | |||
| 229 | + | // --------------------------------------------------------------------------- | |||
| 230 | + | ||||
| 231 | + | func TestSGRDefault(t *testing.T) { | |||
| 232 | + | s := NewScreen(10, 3) | |||
| 233 | + | s.Write([]byte("\x1b[0m")) | |||
| 234 | + | ||||
| 235 | + | if s.Style != (renderer.DefaultStyle()) { | |||
| 236 | + | t.Errorf("style after SGR 0 = %+v, want default", s.Style) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestSGRBold(t *testing.T) { | |||
| 241 | + | s := NewScreen(10, 3) | |||
| 242 | + | s.Write([]byte("\x1b[1m")) | |||
| 243 | + | ||||
| 244 | + | if !s.Style.Bold { | |||
| 245 | + | t.Error("expected bold") | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestSGRInverse(t *testing.T) { | |||
| 250 | + | s := NewScreen(10, 3) | |||
| 251 | + | s.Write([]byte("\x1b[7m")) | |||
| 252 | + | ||||
| 253 | + | if !s.Style.Inverse { | |||
| 254 | + | t.Error("expected inverse") | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func TestSGRForeground(t *testing.T) { | |||
| 259 | + | s := NewScreen(10, 3) | |||
| 260 | + | s.Write([]byte("\x1b[31m")) // red | |||
| 261 | + | ||||
| 262 | + | if s.Style.Foreground != 1 { | |||
| 263 | + | t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestSGRBackground(t *testing.T) { | |||
| 268 | + | s := NewScreen(10, 3) | |||
| 269 | + | s.Write([]byte("\x1b[44m")) // blue background | |||
| 270 | + | ||||
| 271 | + | if s.Style.Background != 4 { | |||
| 272 | + | t.Errorf("background = %d, want 4 (blue)", s.Style.Background) | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestSGR256Color(t *testing.T) { | |||
| 277 | + | s := NewScreen(10, 3) | |||
| 278 | + | s.Write([]byte("\x1b[38;5;82m")) // 256-color foreground | |||
| 279 | + | ||||
| 280 | + | if s.Style.Foreground != 82 { | |||
| 281 | + | t.Errorf("foreground = %d, want 82", s.Style.Foreground) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func TestSGR256Background(t *testing.T) { | |||
| 286 | + | s := NewScreen(10, 3) | |||
| 287 | + | s.Write([]byte("\x1b[48;5;200m")) // 256-color background | |||
| 288 | + | ||||
| 289 | + | if s.Style.Background != 200 { | |||
| 290 | + | t.Errorf("background = %d, want 200", s.Style.Background) | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestSGRBrightForeground(t *testing.T) { | |||
| 295 | + | s := NewScreen(10, 3) | |||
| 296 | + | s.Write([]byte("\x1b[91m")) // bright red | |||
| 297 | + | ||||
| 298 | + | if s.Style.Foreground != 9 { | |||
| 299 | + | t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func TestSGRBrightBackground(t *testing.T) { | |||
| 304 | + | s := NewScreen(10, 3) | |||
| 305 | + | s.Write([]byte("\x1b[107m")) // bright white background | |||
| 306 | + | ||||
| 307 | + | if s.Style.Background != 15 { | |||
| 308 | + | t.Errorf("background = %d, want 15 (bright white)", s.Style.Background) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func TestSGRTruecolorForeground(t *testing.T) { | |||
| 313 | + | s := NewScreen(10, 3) | |||
| 314 | + | s.Write([]byte("\x1b[38;2;12;34;56mX")) | |||
| 315 | + | ||||
| 316 | + | want := renderer.RGB{R: 12, G: 34, B: 56} | |||
| 317 | + | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |||
| 318 | + | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |||
| 319 | + | } | |||
| 320 | + | if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { | |||
| 321 | + | t.Errorf("cell foreground RGB = %+v, want %+v", got, want) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func TestSGRTruecolorBackground(t *testing.T) { | |||
| 326 | + | s := NewScreen(10, 3) | |||
| 327 | + | s.Write([]byte("\x1b[48;2;200;100;50m")) | |||
| 328 | + | ||||
| 329 | + | want := renderer.RGB{R: 200, G: 100, B: 50} | |||
| 330 | + | if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want { | |||
| 331 | + | t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want) | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func TestSGRDefaultForegroundClearsTruecolor(t *testing.T) { | |||
| 336 | + | s := NewScreen(10, 3) | |||
| 337 | + | s.Write([]byte("\x1b[38;2;12;34;56m\x1b[39m")) | |||
| 338 | + | ||||
| 339 | + | if s.Style.HasForegroundRGB || s.Style.Foreground != -1 { | |||
| 340 | + | t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground) | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestSGRTruecolorClampsComponents(t *testing.T) { | |||
| 345 | + | s := NewScreen(10, 3) | |||
| 346 | + | s.Write([]byte("\x1b[38;2;-1;300;42m")) | |||
| 347 | + | ||||
| 348 | + | want := renderer.RGB{R: 0, G: 255, B: 42} | |||
| 349 | + | if s.Style.ForegroundRGB != want { | |||
| 350 | + | t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) { | |||
| 355 | + | tests := []struct { | |||
| 356 | + | name string | |||
| 357 | + | seq string | |||
| 358 | + | foreground int | |||
| 359 | + | background int | |||
| 360 | + | hasForeground bool | |||
| 361 | + | hasBackground bool | |||
| 362 | + | }{ | |||
| 363 | + | {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1}, | |||
| 364 | + | {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1}, | |||
| 365 | + | {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true}, | |||
| 366 | + | {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15}, | |||
| 367 | + | {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200}, | |||
| 368 | + | {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1}, | |||
| 369 | + | {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true}, | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | for _, tt := range tests { | |||
| 373 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 374 | + | s := NewScreen(10, 3) | |||
| 375 | + | s.Write([]byte(tt.seq)) | |||
| 376 | + | ||||
| 377 | + | if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground { | |||
| 378 | + | t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground) | |||
| 379 | + | } | |||
| 380 | + | if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground { | |||
| 381 | + | t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground) | |||
| 382 | + | } | |||
| 383 | + | }) | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func TestSGRResetBold(t *testing.T) { | |||
| 388 | + | s := NewScreen(10, 3) | |||
| 389 | + | s.Write([]byte("\x1b[1mHello\x1b[22mWorld")) | |||
| 390 | + | ||||
| 391 | + | if s.Style.Bold { | |||
| 392 | + | t.Error("bold should be reset after 22") | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestSGRResetInverse(t *testing.T) { | |||
| 397 | + | s := NewScreen(10, 3) | |||
| 398 | + | s.Write([]byte("\x1b[7m\x1b[27m")) | |||
| 399 | + | ||||
| 400 | + | if s.Style.Inverse { | |||
| 401 | + | t.Error("inverse should be reset after 27") | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func TestSGRDefaultForeground(t *testing.T) { | |||
| 406 | + | s := NewScreen(10, 3) | |||
| 407 | + | s.Write([]byte("\x1b[31m\x1b[39m")) | |||
| 408 | + | ||||
| 409 | + | if s.Style.Foreground != -1 { | |||
| 410 | + | t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground) | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func TestSGRDefaultBackground(t *testing.T) { | |||
| 415 | + | s := NewScreen(10, 3) | |||
| 416 | + | s.Write([]byte("\x1b[44m\x1b[49m")) | |||
| 417 | + | ||||
| 418 | + | if s.Style.Background != -1 { | |||
| 419 | + | t.Errorf("background should be default after 49, got %d", s.Style.Background) | |||
| 420 | + | } | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func TestSGRAppliesToCells(t *testing.T) { | |||
| 424 | + | s := NewScreen(10, 3) | |||
| 425 | + | s.Write([]byte("\x1b[1;31mX")) | |||
| 426 | + | ||||
| 427 | + | c := cellAt(s, 0, 0) | |||
| 428 | + | if !c.Style.Bold { | |||
| 429 | + | t.Error("cell should be bold") | |||
| 430 | + | } | |||
| 431 | + | if c.Style.Foreground != 1 { | |||
| 432 | + | t.Errorf("cell foreground = %d, want 1", c.Style.Foreground) | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | // --------------------------------------------------------------------------- | |||
| 437 | + | // Clear screen | |||
| 438 | + | // --------------------------------------------------------------------------- | |||
| 439 | + | ||||
| 440 | + | func TestClearScreen(t *testing.T) { | |||
| 441 | + | s := NewScreen(5, 3) | |||
| 442 | + | s.Write([]byte("HelloWorldABCDE")) | |||
| 443 | + | // Clear damage from writing. | |||
| 444 | + | s.ClearDamage() | |||
| 445 | + | ||||
| 446 | + | s.Write([]byte("\x1b[2J")) | |||
| 447 | + | ||||
| 448 | + | // All cells should be blank. | |||
| 449 | + | for y := 0; y < 3; y++ { | |||
| 450 | + | for x := 0; x < 5; x++ { | |||
| 451 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 452 | + | t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune) | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | d := s.Damage() | |||
| 458 | + | if !hasDamageKind(d, renderer.DamageClear) { | |||
| 459 | + | t.Error("expected DamageClear after CSI 2 J") | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | // --------------------------------------------------------------------------- | |||
| 464 | + | // Clear line | |||
| 465 | + | // --------------------------------------------------------------------------- | |||
| 466 | + | ||||
| 467 | + | func TestClearLine(t *testing.T) { | |||
| 468 | + | s := NewScreen(10, 3) | |||
| 469 | + | s.Write([]byte("HelloWorld")) | |||
| 470 | + | s.ClearDamage() | |||
| 471 | + | ||||
| 472 | + | // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end. | |||
| 473 | + | s.Write([]byte("\x1b[1;6H")) | |||
| 474 | + | s.Write([]byte("\x1b[K")) | |||
| 475 | + | ||||
| 476 | + | for x := 5; x < 10; x++ { | |||
| 477 | + | if c := cellAt(s, x, 0); c.Rune != ' ' { | |||
| 478 | + | t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune) | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | // First 5 chars should remain. | |||
| 482 | + | assertCell(t, s, 0, 0, 'H') | |||
| 483 | + | ||||
| 484 | + | d := s.Damage() | |||
| 485 | + | if !hasDamageKind(d, renderer.DamageClear) { | |||
| 486 | + | t.Error("expected DamageClear after CSI K") | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | // --------------------------------------------------------------------------- | |||
| 491 | + | // Cursor move | |||
| 492 | + | // --------------------------------------------------------------------------- | |||
| 493 | + | ||||
| 494 | + | func TestCursorMoveH(t *testing.T) { | |||
| 495 | + | s := NewScreen(10, 5) | |||
| 496 | + | s.Write([]byte("\x1b[3;4H")) | |||
| 497 | + | ||||
| 498 | + | if s.Row != 2 || s.Col != 3 { | |||
| 499 | + | t.Errorf("cursor at row=%d col=%d, want row=2 col=3", s.Row, s.Col) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func TestCursorMoveOutsideBounds(t *testing.T) { | |||
| 504 | + | s := NewScreen(10, 5) | |||
| 505 | + | s.Write([]byte("\x1b[100;200H")) | |||
| 506 | + | ||||
| 507 | + | // Should clamp to bottom-right. | |||
| 508 | + | if s.Row != 4 || s.Col != 9 { | |||
| 509 | + | t.Errorf("cursor at row=%d col=%d, want row=4 col=9", s.Row, s.Col) | |||
| 510 | + | } | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | func TestCursorMoveF(t *testing.T) { | |||
| 514 | + | s := NewScreen(10, 5) | |||
| 515 | + | s.Write([]byte("\x1b[2;8f")) | |||
| 516 | + | ||||
| 517 | + | if s.Row != 1 || s.Col != 7 { | |||
| 518 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=7", s.Row, s.Col) | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | func TestCursorMoveForwardC(t *testing.T) { | |||
| 523 | + | s := NewScreen(10, 5) | |||
| 524 | + | s.Write([]byte("\r\x1b[3C")) | |||
| 525 | + | ||||
| 526 | + | if s.Row != 0 || s.Col != 3 { | |||
| 527 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col) | |||
| 528 | + | } | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | func TestESC_DECKPAMIgnored(t *testing.T) { | |||
| 532 | + | s := NewScreen(10, 2) | |||
| 533 | + | s.Write([]byte("\x1b=abc")) | |||
| 534 | + | ||||
| 535 | + | assertCell(t, s, 0, 0, 'a') | |||
| 536 | + | assertCell(t, s, 1, 0, 'b') | |||
| 537 | + | assertCell(t, s, 2, 0, 'c') | |||
| 538 | + | if s.Col != 3 || s.Row != 0 { | |||
| 539 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col) | |||
| 540 | + | } | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func TestESC_SaveRestoreCursor(t *testing.T) { | |||
| 544 | + | s := NewScreen(10, 3) | |||
| 545 | + | s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X")) | |||
| 546 | + | ||||
| 547 | + | assertCell(t, s, 0, 0, 'a') | |||
| 548 | + | assertCell(t, s, 1, 0, 'b') | |||
| 549 | + | assertCell(t, s, 2, 0, 'c') | |||
| 550 | + | assertCell(t, s, 3, 0, 'X') | |||
| 551 | + | assertCell(t, s, 4, 1, 'Z') | |||
| 552 | + | assertCell(t, s, 5, 1, 'Z') | |||
| 553 | + | if s.Row != 0 || s.Col != 4 { | |||
| 554 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col) | |||
| 555 | + | } | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | func TestESC_IndexNextLineAndReverseIndex(t *testing.T) { | |||
| 559 | + | s := NewScreen(5, 3) | |||
| 560 | + | s.Write([]byte("A\x1bDB\x1bEC")) | |||
| 561 | + | ||||
| 562 | + | assertCell(t, s, 0, 0, 'A') | |||
| 563 | + | assertCell(t, s, 1, 1, 'B') | |||
| 564 | + | assertCell(t, s, 0, 2, 'C') | |||
| 565 | + | ||||
| 566 | + | s = NewScreen(5, 3) | |||
| 567 | + | s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop")) | |||
| 568 | + | assertCell(t, s, 0, 0, 't') | |||
| 569 | + | assertCell(t, s, 0, 1, 'm') | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | func TestCSI_CursorDirectionalMoves(t *testing.T) { | |||
| 573 | + | s := NewScreen(10, 4) | |||
| 574 | + | s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>")) | |||
| 575 | + | ||||
| 576 | + | assertCell(t, s, 4, 2, 'X') | |||
| 577 | + | assertCell(t, s, 3, 2, '<') | |||
| 578 | + | assertCell(t, s, 4, 1, '^') | |||
| 579 | + | assertCell(t, s, 5, 2, 'v') | |||
| 580 | + | assertCell(t, s, 9, 2, '>') | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | func TestCSI_InsertDeleteCharacters(t *testing.T) { | |||
| 584 | + | s := NewScreen(8, 2) | |||
| 585 | + | s.Write([]byte("abcdef")) | |||
| 586 | + | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | |||
| 587 | + | if got := lineText(s, 0); got != "abXYcdef" { | |||
| 588 | + | t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef") | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | s.Write([]byte("\x1b[1;4H\x1b[3P")) | |||
| 592 | + | if got := lineText(s, 0); got != "abXef " { | |||
| 593 | + | t.Fatalf("line after DCH = %q, want %q", got, "abXef ") | |||
| 594 | + | } | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | func TestCSI_InsertDeleteLines(t *testing.T) { | |||
| 598 | + | s := NewScreen(5, 4) | |||
| 599 | + | s.Write([]byte("11111222223333344444")) | |||
| 600 | + | s.Write([]byte("\x1b[2;1H\x1b[L")) | |||
| 601 | + | if got := lineText(s, 1); got != " " { | |||
| 602 | + | t.Fatalf("inserted line = %q, want blank", got) | |||
| 603 | + | } | |||
| 604 | + | if got := lineText(s, 2); got != "22222" { | |||
| 605 | + | t.Fatalf("shifted line = %q, want 22222", got) | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | s.Write([]byte("\x1b[2;1H\x1b[M")) | |||
| 609 | + | if got := lineText(s, 1); got != "22222" { | |||
| 610 | + | t.Fatalf("line after delete = %q, want 22222", got) | |||
| 611 | + | } | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | func TestCSI_ScrollRegion(t *testing.T) { | |||
| 615 | + | s := NewScreen(5, 4) | |||
| 616 | + | s.Write([]byte("11111222223333344444")) | |||
| 617 | + | s.Write([]byte("\x1b[2;3r\x1b[3;1H\n")) | |||
| 618 | + | ||||
| 619 | + | if got := lineText(s, 0); got != "11111" { | |||
| 620 | + | t.Fatalf("row outside region changed: %q", got) | |||
| 621 | + | } | |||
| 622 | + | if got := lineText(s, 1); got != "33333" { | |||
| 623 | + | t.Fatalf("region did not scroll up, row 1 = %q", got) | |||
| 624 | + | } | |||
| 625 | + | if got := lineText(s, 2); got != " " { | |||
| 626 | + | t.Fatalf("region bottom not blanked, row 2 = %q", got) | |||
| 627 | + | } | |||
| 628 | + | if got := lineText(s, 3); got != "44444" { | |||
| 629 | + | t.Fatalf("row below region changed: %q", got) | |||
| 630 | + | } | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | func TestCSI_PrivateAlternateScreenRestoresNormalScreen(t *testing.T) { | |||
| 634 | + | s := NewScreen(20, 5) | |||
| 635 | + | s.Write([]byte("normal-line")) | |||
| 636 | + | s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP")) | |||
| 637 | + | s.Write([]byte("\x1b[?1049lafter")) | |||
| 638 | + | ||||
| 639 | + | if got := lineText(s, 0); got != "normal-lineafter " { | |||
| 640 | + | t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got) | |||
| 641 | + | } | |||
| 642 | + | if got := lineText(s, 2); got != " " { | |||
| 643 | + | t.Fatalf("alternate content leaked into row 2: %q", got) | |||
| 644 | + | } | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | func TestRendererStaysInSyncAfterVTEditDamage(t *testing.T) { | |||
| 648 | + | s := NewScreen(8, 2) | |||
| 649 | + | r := renderer.New(renderer.Capabilities{}) | |||
| 650 | + | s.Write([]byte("abcdef")) | |||
| 651 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 652 | + | t.Fatal(err) | |||
| 653 | + | } | |||
| 654 | + | s.ClearDamage() | |||
| 655 | + | ||||
| 656 | + | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | |||
| 657 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 658 | + | t.Fatal(err) | |||
| 659 | + | } | |||
| 660 | + | s.ClearDamage() | |||
| 661 | + | ||||
| 662 | + | out, err := r.Draw(s.Frame, nil) | |||
| 663 | + | if err != nil { | |||
| 664 | + | t.Fatal(err) | |||
| 665 | + | } | |||
| 666 | + | if len(out) != 0 { | |||
| 667 | + | t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out)) | |||
| 668 | + | } | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | func TestDCS_STTerminatedIgnored(t *testing.T) { | |||
| 672 | + | s := NewScreen(20, 2) | |||
| 673 | + | s.Write([]byte("\x1bP+q696e646e\x1b\\prompt")) | |||
| 674 | + | ||||
| 675 | + | assertCell(t, s, 0, 0, 'p') | |||
| 676 | + | assertCell(t, s, 1, 0, 'r') | |||
| 677 | + | assertCell(t, s, 2, 0, 'o') | |||
| 678 | + | assertCell(t, s, 3, 0, 'm') | |||
| 679 | + | assertCell(t, s, 4, 0, 'p') | |||
| 680 | + | assertCell(t, s, 5, 0, 't') | |||
| 681 | + | if s.Col != 6 || s.Row != 0 { | |||
| 682 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col) | |||
| 683 | + | } | |||
| 684 | + | } | |||
| 685 | + | ||||
| 686 | + | func TestFishLikePromptRedrawPreservesTypedCharacters(t *testing.T) { | |||
| 687 | + | s := NewScreen(10, 2) | |||
| 688 | + | s.Write([]byte("> ")) | |||
| 689 | + | s.Write([]byte("a\r\x1b[3C")) | |||
| 690 | + | s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C")) | |||
| 691 | + | ||||
| 692 | + | assertCell(t, s, 0, 0, '>') | |||
| 693 | + | assertCell(t, s, 1, 0, ' ') | |||
| 694 | + | assertCell(t, s, 2, 0, 'a') | |||
| 695 | + | assertCell(t, s, 3, 0, 'b') | |||
| 696 | + | if s.Row != 0 || s.Col != 4 { | |||
| 697 | + | t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col) | |||
| 698 | + | } | |||
| 699 | + | } | |||
| 700 | + | ||||
| 701 | + | // --------------------------------------------------------------------------- | |||
| 702 | + | // Scroll damage | |||
| 703 | + | // --------------------------------------------------------------------------- | |||
| 704 | + | ||||
| 705 | + | func TestScrollDamageKind(t *testing.T) { | |||
| 706 | + | s := NewScreen(5, 2) | |||
| 707 | + | // Fill screen then cause a scroll. | |||
| 708 | + | s.Write([]byte("AAAAABBBBB")) // both rows filled | |||
| 709 | + | s.ClearDamage() | |||
| 710 | + | ||||
| 711 | + | s.Write([]byte("CCCCC")) // forces a scroll up | |||
| 712 | + | ||||
| 713 | + | d := s.Damage() | |||
| 714 | + | if len(d) == 0 { | |||
| 715 | + | t.Fatal("expected damage after scroll") | |||
| 716 | + | } | |||
| 717 | + | ||||
| 718 | + | if !hasDamageKind(d, renderer.DamageScrollUp) { | |||
| 719 | + | t.Errorf("expected DamageScrollUp in %v", damageKinds(d)) | |||
| 720 | + | } | |||
| 721 | + | if !hasDamageKind(d, renderer.DamageText) { | |||
| 722 | + | t.Errorf("expected DamageText in %v", damageKinds(d)) | |||
| 723 | + | } | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | func TestScrollDamageCoordinates(t *testing.T) { | |||
| 727 | + | s := NewScreen(5, 3) | |||
| 728 | + | s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows | |||
| 729 | + | s.ClearDamage() | |||
| 730 | + | ||||
| 731 | + | // Write another char to force scroll. | |||
| 732 | + | s.Write([]byte("D")) | |||
| 733 | + | ||||
| 734 | + | d := s.Damage() | |||
| 735 | + | var scrollDamage *renderer.Damage | |||
| 736 | + | for i, dd := range d { | |||
| 737 | + | if dd.Kind == renderer.DamageScrollUp { | |||
| 738 | + | scrollDamage = &d[i] | |||
| 739 | + | break | |||
| 740 | + | } | |||
| 741 | + | } | |||
| 742 | + | if scrollDamage == nil { | |||
| 743 | + | t.Fatal("expected scroll damage") | |||
| 744 | + | } | |||
| 745 | + | if scrollDamage.X != 0 || scrollDamage.Y != 0 { | |||
| 746 | + | t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y) | |||
| 747 | + | } | |||
| 748 | + | if scrollDamage.Width != 5 || scrollDamage.Height != 3 { | |||
| 749 | + | t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height) | |||
| 750 | + | } | |||
| 751 | + | if scrollDamage.Count != 1 { | |||
| 752 | + | t.Errorf("scroll count = %d, want 1", scrollDamage.Count) | |||
| 753 | + | } | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | // --------------------------------------------------------------------------- | |||
| 757 | + | // Damage coalescing | |||
| 758 | + | // --------------------------------------------------------------------------- | |||
| 759 | + | ||||
| 760 | + | func TestDamageCoalescing(t *testing.T) { | |||
| 761 | + | s := NewScreen(20, 3) | |||
| 762 | + | s.Write([]byte("ABC")) // Three adjacent chars on one line | |||
| 763 | + | ||||
| 764 | + | d := s.Damage() | |||
| 765 | + | // Should be coalesced into a single DamageText. | |||
| 766 | + | if len(d) != 1 { | |||
| 767 | + | t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d) | |||
| 768 | + | } | |||
| 769 | + | if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 { | |||
| 770 | + | t.Errorf("unexpected coalesced damage: %+v", d[0]) | |||
| 771 | + | } | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | func TestDamageCoalescingNewlineBreaks(t *testing.T) { | |||
| 775 | + | s := NewScreen(10, 3) | |||
| 776 | + | s.Write([]byte("AB\nCD")) | |||
| 777 | + | ||||
| 778 | + | d := s.Damage() | |||
| 779 | + | // "AB" on line 0, "CD" on line 1 → two separate DamageText items. | |||
| 780 | + | textCount := 0 | |||
| 781 | + | for _, dd := range d { | |||
| 782 | + | if dd.Kind == renderer.DamageText { | |||
| 783 | + | textCount++ | |||
| 784 | + | } | |||
| 785 | + | } | |||
| 786 | + | if textCount < 2 { | |||
| 787 | + | t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount) | |||
| 788 | + | } | |||
| 789 | + | } | |||
| 790 | + | ||||
| 791 | + | func TestDamageCoalescingFullRedraw(t *testing.T) { | |||
| 792 | + | s := NewScreen(5, 3) | |||
| 793 | + | // NewScreen already has FullRedraw in damage. | |||
| 794 | + | d := s.Damage() | |||
| 795 | + | if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw { | |||
| 796 | + | t.Fatalf("expected single FullRedraw, got %+v", d) | |||
| 797 | + | } | |||
| 798 | + | ||||
| 799 | + | // Writing should replace it with text damage. | |||
| 800 | + | s.Write([]byte("X")) | |||
| 801 | + | d = s.Damage() | |||
| 802 | + | if len(d) == 0 { | |||
| 803 | + | t.Fatal("expected damage after write") | |||
| 804 | + | } | |||
| 805 | + | if d[0].Kind == renderer.DamageFullRedraw { | |||
| 806 | + | t.Error("FullRedraw should have been replaced by text damage") | |||
| 807 | + | } | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | // --------------------------------------------------------------------------- | |||
| 811 | + | // Resize | |||
| 812 | + | // --------------------------------------------------------------------------- | |||
| 813 | + | ||||
| 814 | + | func TestResizeLarger(t *testing.T) { | |||
| 815 | + | s := NewScreen(5, 3) | |||
| 816 | + | s.Write([]byte("Hello")) | |||
| 817 | + | s.Resize(10, 5) | |||
| 818 | + | ||||
| 819 | + | if s.Frame.Width != 10 || s.Frame.Height != 5 { | |||
| 820 | + | t.Errorf("frame size = %dx%d, want 10x5", s.Frame.Width, s.Frame.Height) | |||
| 821 | + | } | |||
| 822 | + | // Should be blank (new frame). | |||
| 823 | + | for x := 0; x < 10; x++ { | |||
| 824 | + | assertCell(t, s, x, 0, ' ') | |||
| 825 | + | } | |||
| 826 | + | // Cursor reset. | |||
| 827 | + | if s.Row != 0 || s.Col != 0 { | |||
| 828 | + | t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col) | |||
| 829 | + | } | |||
| 830 | + | // Should have FullRedraw. | |||
| 831 | + | d := s.Damage() | |||
| 832 | + | if !hasDamageKind(d, renderer.DamageFullRedraw) { | |||
| 833 | + | t.Errorf("expected FullRedraw after resize, got %v", damageKinds(d)) | |||
| 834 | + | } | |||
| 835 | + | } | |||
| 836 | + | ||||
| 837 | + | func TestResizeSameSize(t *testing.T) { | |||
| 838 | + | s := NewScreen(5, 3) | |||
| 839 | + | s.Write([]byte("Hello")) | |||
| 840 | + | ||||
| 841 | + | s.Resize(5, 3) // same size | |||
| 842 | + | ||||
| 843 | + | assertCell(t, s, 0, 0, 'H') // content preserved | |||
| 844 | + | // No FullRedraw. | |||
| 845 | + | d := s.Damage() | |||
| 846 | + | if len(d) != 0 && d[0].Kind == renderer.DamageFullRedraw { | |||
| 847 | + | t.Error("same-size resize should not produce FullRedraw") | |||
| 848 | + | } | |||
| 849 | + | } | |||
| 850 | + | ||||
| 851 | + | // --------------------------------------------------------------------------- | |||
| 852 | + | // ClearDamage | |||
| 853 | + | // --------------------------------------------------------------------------- | |||
| 854 | + | ||||
| 855 | + | func TestClearDamage(t *testing.T) { | |||
| 856 | + | s := NewScreen(5, 3) | |||
| 857 | + | s.Write([]byte("Hello")) | |||
| 858 | + | s.ClearDamage() | |||
| 859 | + | d := s.Damage() | |||
| 860 | + | if len(d) != 0 { | |||
| 861 | + | t.Fatalf("expected empty damage after ClearDamage, got %+v", d) | |||
| 862 | + | } | |||
| 863 | + | } | |||
| 864 | + | ||||
| 865 | + | // --------------------------------------------------------------------------- | |||
| 866 | + | // Non-printable / invalid bytes | |||
| 867 | + | // --------------------------------------------------------------------------- | |||
| 868 | + | ||||
| 869 | + | func TestInvalidBytes(t *testing.T) { | |||
| 870 | + | s := NewScreen(5, 3) | |||
| 871 | + | // Invalid UTF-8 continuation byte without a start byte. | |||
| 872 | + | s.Write([]byte{0x80, 0x81}) | |||
| 873 | + | // Should not panic, and no cells should be modified. | |||
| 874 | + | for y := 0; y < 3; y++ { | |||
| 875 | + | for x := 0; x < 5; x++ { | |||
| 876 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 877 | + | t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune) | |||
| 878 | + | } | |||
| 879 | + | } | |||
| 880 | + | } | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | func TestControlChars(t *testing.T) { | |||
| 884 | + | s := NewScreen(10, 3) | |||
| 885 | + | // Control characters below 0x20 that are not handled (CR/LF/BS/TAB) should be ignored. | |||
| 886 | + | ctrl := []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f} | |||
| 887 | + | s.Write(ctrl) | |||
| 888 | + | // No cells should be modified. | |||
| 889 | + | for y := 0; y < 3; y++ { | |||
| 890 | + | for x := 0; x < 10; x++ { | |||
| 891 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 892 | + | t.Errorf("cell(%d,%d) = %q, want space after control chars", x, y, c.Rune) | |||
| 893 | + | } | |||
| 894 | + | } | |||
| 895 | + | } | |||
| 896 | + | } | |||
| 897 | + | ||||
| 898 | + | // --------------------------------------------------------------------------- | |||
| 899 | + | // Multiple sequential CSI sequences | |||
| 900 | + | // --------------------------------------------------------------------------- | |||
| 901 | + | ||||
| 902 | + | func TestMultipleSGR(t *testing.T) { | |||
| 903 | + | s := NewScreen(10, 3) | |||
| 904 | + | s.Write([]byte("\x1b[31m\x1b[1m\x1b[44mX")) | |||
| 905 | + | ||||
| 906 | + | c := cellAt(s, 0, 0) | |||
| 907 | + | if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 { | |||
| 908 | + | t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style) | |||
| 909 | + | } | |||
| 910 | + | } | |||
| 911 | + | ||||
| 912 | + | func TestSGREmptyParamsReset(t *testing.T) { | |||
| 913 | + | s := NewScreen(10, 3) | |||
| 914 | + | s.Write([]byte("\x1b[31m\x1b[1m\x1b[m")) // empty params → reset | |||
| 915 | + | ||||
| 916 | + | if s.Style != (renderer.DefaultStyle()) { | |||
| 917 | + | t.Errorf("style after empty SGR = %+v, want default", s.Style) | |||
| 918 | + | } | |||
| 919 | + | } | |||
| 920 | + | ||||
| 921 | + | // --------------------------------------------------------------------------- | |||
| 922 | + | // Cursor move with zero / missing params | |||
| 923 | + | // --------------------------------------------------------------------------- | |||
| 924 | + | ||||
| 925 | + | func TestCursorMoveZeroParams(t *testing.T) { | |||
| 926 | + | s := NewScreen(10, 5) | |||
| 927 | + | s.Write([]byte("\x1b[H")) // no params → (1,1) | |||
| 928 | + | ||||
| 929 | + | if s.Row != 0 || s.Col != 0 { | |||
| 930 | + | t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col) | |||
| 931 | + | } | |||
| 932 | + | } | |||
| 933 | + | ||||
| 934 | + | func TestCursorMovePartialParams(t *testing.T) { | |||
| 935 | + | s := NewScreen(10, 5) | |||
| 936 | + | s.Row, s.Col = 3, 4 | |||
| 937 | + | s.Write([]byte("\x1b[;5H")) // row=1 (default), col=5 | |||
| 938 | + | ||||
| 939 | + | if s.Row != 0 || s.Col != 4 { | |||
| 940 | + | t.Errorf("cursor at (%d,%d), want (0,4)", s.Row, s.Col) | |||
| 941 | + | } | |||
| 942 | + | } | |||
| 943 | + | ||||
| 944 | + | // --------------------------------------------------------------------------- | |||
| 945 | + | // No overflow on edge writes | |||
| 946 | + | // --------------------------------------------------------------------------- | |||
| 947 | + | ||||
| 948 | + | func TestWriteAtEdgeNoPanic(t *testing.T) { | |||
| 949 | + | s := NewScreen(1, 1) | |||
| 950 | + | // Fill the only cell then try to write more (should scroll). | |||
| 951 | + | s.Write([]byte("ABC")) | |||
| 952 | + | // Should not panic. | |||
| 953 | + | _ = cellAt(s, 0, 0) | |||
| 954 | + | } | |||
| 955 | + | ||||
| 956 | + | // --------------------------------------------------------------------------- | |||
| 957 | + | // Style persistence across writes | |||
| 958 | + | // --------------------------------------------------------------------------- | |||
| 959 | + | ||||
| 960 | + | func TestStylePersists(t *testing.T) { | |||
| 961 | + | s := NewScreen(10, 3) | |||
| 962 | + | s.Write([]byte("\x1b[1mAB")) | |||
| 963 | + | s.Write([]byte("CD")) | |||
| 964 | + | ||||
| 965 | + | c1 := cellAt(s, 0, 0) | |||
| 966 | + | c2 := cellAt(s, 2, 0) | |||
| 967 | + | if !c1.Style.Bold || !c2.Style.Bold { | |||
| 968 | + | t.Error("bold should persist across Write calls") | |||
| 969 | + | } | |||
| 970 | + | } | |||
| 971 | + | ||||
| 972 | + | // --------------------------------------------------------------------------- | |||
| 973 | + | // Rune width (CJK, emoji, combining marks, zero-width) | |||
| 974 | + | // --------------------------------------------------------------------------- | |||
| 975 | + | ||||
| 976 | + | func TestCJKWideChars(t *testing.T) { | |||
| 977 | + | s := NewScreen(10, 3) | |||
| 978 | + | // Write two CJK characters that are width 2 in a real terminal. | |||
| 979 | + | // They should be rendered as '?' placeholders, advancing cursor by 1 each. | |||
| 980 | + | s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd")) // 你好 | |||
| 981 | + | ||||
| 982 | + | assertCell(t, s, 0, 0, '?') | |||
| 983 | + | assertCell(t, s, 1, 0, '?') | |||
| 984 | + | if s.Col != 2 || s.Row != 0 { | |||
| 985 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 986 | + | } | |||
| 987 | + | } | |||
| 988 | + | ||||
| 989 | + | func TestCJKMixedWithASCII(t *testing.T) { | |||
| 990 | + | s := NewScreen(10, 3) | |||
| 991 | + | s.Write([]byte("A\xe4\xbd\xa0B")) // A你B | |||
| 992 | + | ||||
| 993 | + | assertCell(t, s, 0, 0, 'A') | |||
| 994 | + | assertCell(t, s, 1, 0, '?') | |||
| 995 | + | assertCell(t, s, 2, 0, 'B') | |||
| 996 | + | if s.Col != 3 { | |||
| 997 | + | t.Errorf("cursor at col=%d, want 3", s.Col) | |||
| 998 | + | } | |||
| 999 | + | } | |||
| 1000 | + | ||||
| 1001 | + | func TestEmojiRenderedAsPlaceholder(t *testing.T) { | |||
| 1002 | + | s := NewScreen(10, 3) | |||
| 1003 | + | em := []byte("\U0001f600") // 😀 grinning face | |||
| 1004 | + | s.Write(em) | |||
| 1005 | + | ||||
| 1006 | + | // Wide emoji is rendered as '?' placeholder. | |||
| 1007 | + | assertCell(t, s, 0, 0, '?') | |||
| 1008 | + | if s.Col != 1 { | |||
| 1009 | + | t.Errorf("cursor at col=%d, want 1", s.Col) | |||
| 1010 | + | } | |||
| 1011 | + | } | |||
| 1012 | + | ||||
| 1013 | + | func TestCombiningMarkSkipped(t *testing.T) { | |||
| 1014 | + | s := NewScreen(10, 3) | |||
| 1015 | + | // 'A' followed by combining acute accent U+0301 | |||
| 1016 | + | s.Write([]byte("A\xcc\x81")) | |||
| 1017 | + | ||||
| 1018 | + | assertCell(t, s, 0, 0, 'A') | |||
| 1019 | + | // Combining mark should be skipped — no cell written, no cursor advance. | |||
| 1020 | + | if s.Col != 1 { | |||
| 1021 | + | t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col) | |||
| 1022 | + | } | |||
| 1023 | + | } | |||
| 1024 | + | ||||
| 1025 | + | func TestZeroWidthCharsSkipped(t *testing.T) { | |||
| 1026 | + | s := NewScreen(10, 3) | |||
| 1027 | + | // Zero-width space U+200B | |||
| 1028 | + | s.Write([]byte("A\xe2\x80\x8bB")) | |||
| 1029 | + | ||||
| 1030 | + | assertCell(t, s, 0, 0, 'A') | |||
| 1031 | + | assertCell(t, s, 1, 0, 'B') | |||
| 1032 | + | if s.Col != 2 { | |||
| 1033 | + | t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col) | |||
| 1034 | + | } | |||
| 1035 | + | } | |||
| 1036 | + | ||||
| 1037 | + | func TestRuneWidth(t *testing.T) { | |||
| 1038 | + | tests := []struct { | |||
| 1039 | + | r rune | |||
| 1040 | + | width int | |||
| 1041 | + | }{ | |||
| 1042 | + | {' ', 1}, | |||
| 1043 | + | {'a', 1}, | |||
| 1044 | + | {'A', 1}, | |||
| 1045 | + | {'1', 1}, | |||
| 1046 | + | {0x00AD, 0}, // soft hyphen | |||
| 1047 | + | {0x034F, 0}, // combining grapheme joiner | |||
| 1048 | + | {0x0301, 0}, // combining acute accent | |||
| 1049 | + | {0x0300, 0}, // combining grave accent | |||
| 1050 | + | {0x200B, 0}, // zero-width space | |||
| 1051 | + | {0x200C, 0}, // zero-width non-joiner | |||
| 1052 | + | {0x200D, 0}, // zero-width joiner | |||
| 1053 | + | {0xFEFF, 0}, // BOM / zero-width no-break space | |||
| 1054 | + | {0x4E00, 2}, // CJK Unified Ideograph (一) | |||
| 1055 | + | {0x4E8C, 2}, // CJK (二) | |||
| 1056 | + | {0x9FFF, 2}, // CJK end of BMP | |||
| 1057 | + | {0x3400, 2}, // CJK Extension A | |||
| 1058 | + | {0xAC00, 2}, // Hangul Syllable (가) | |||
| 1059 | + | {0xD7AF, 2}, // Hangul Syllable end | |||
| 1060 | + | {0x3000, 2}, // CJK Ideographic Space | |||
| 1061 | + | {0xFF01, 2}, // Fullwidth Exclamation Mark | |||
| 1062 | + | {0xFFE0, 2}, // Fullwidth Cent Sign | |||
| 1063 | + | {0x1F600, 2}, // 😀 | |||
| 1064 | + | {0x1F680, 2}, // 🚀 | |||
| 1065 | + | {'\x00', 0}, | |||
| 1066 | + | {'\x01', 0}, | |||
| 1067 | + | {'\x1b', 0}, | |||
| 1068 | + | {0x7F, 0}, | |||
| 1069 | + | } | |||
| 1070 | + | ||||
| 1071 | + | for _, tt := range tests { | |||
| 1072 | + | got := runeWidth(tt.r) | |||
| 1073 | + | if got != tt.width { | |||
| 1074 | + | t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) | |||
| 1075 | + | } | |||
| 1076 | + | } | |||
| 1077 | + | } | |||
| 1078 | + | ||||
| 1079 | + | func TestDamageReturnsReference(t *testing.T) { | |||
| 1080 | + | // Damage() should return a reference to internal slice (no copy). | |||
| 1081 | + | s := NewScreen(10, 3) | |||
| 1082 | + | s.Write([]byte("X")) | |||
| 1083 | + | d1 := s.Damage() | |||
| 1084 | + | // Internal slice should be the same (or at least reference the same backing). | |||
| 1085 | + | if len(d1) == 0 { | |||
| 1086 | + | t.Fatal("expected damage") | |||
| 1087 | + | } | |||
| 1088 | + | // Write more to trigger record. | |||
| 1089 | + | s.Write([]byte("Y")) | |||
| 1090 | + | d2 := s.Damage() | |||
| 1091 | + | _ = d2 | |||
| 1092 | + | // Both d1 and d2 reference the internal slice; we just don't crash. | |||
| 1093 | + | } | |||
| 1094 | + | ||||
| 1095 | + | func TestCJKSurroundedByASCII(t *testing.T) { | |||
| 1096 | + | s := NewScreen(10, 3) | |||
| 1097 | + | s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc")) // a你b好c | |||
| 1098 | + | ||||
| 1099 | + | assertCell(t, s, 0, 0, 'a') | |||
| 1100 | + | assertCell(t, s, 1, 0, '?') | |||
| 1101 | + | assertCell(t, s, 2, 0, 'b') | |||
| 1102 | + | assertCell(t, s, 3, 0, '?') | |||
| 1103 | + | assertCell(t, s, 4, 0, 'c') | |||
| 1104 | + | if s.Col != 5 { | |||
| 1105 | + | t.Errorf("cursor at col=%d, want 5", s.Col) | |||
| 1106 | + | } | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | func TestCJKAtEdgeWraps(t *testing.T) { | |||
| 1110 | + | s := NewScreen(2, 1) | |||
| 1111 | + | // Write two ASCII chars to fill the line. | |||
| 1112 | + | s.Write([]byte("AB")) // Cells = [A B], Col=2 | |||
| 1113 | + | // CJK at col 2 (edge) triggers newline → scroll on 1-row screen. | |||
| 1114 | + | // scrollUp blanks the only row, then '?' is written at col 0. | |||
| 1115 | + | s.Write([]byte("\xe4\xbd\xa0")) // 你 | |||
| 1116 | + | ||||
| 1117 | + | assertCell(t, s, 0, 0, '?') // CJK placeholder after scroll+blank | |||
| 1118 | + | assertCell(t, s, 1, 0, ' ') // untouched | |||
| 1119 | + | if s.Col != 1 || s.Row != 0 { | |||
| 1120 | + | t.Errorf("cursor at col=%d row=%d, want col=1 row=0", s.Col, s.Row) | |||
| 1121 | + | } | |||
| 1122 | + | } | |||
| 1123 | + | ||||
| 1124 | + | func TestCJKDamageWidthNormalized(t *testing.T) { | |||
| 1125 | + | s := NewScreen(10, 3) | |||
| 1126 | + | s.ClearDamage() | |||
| 1127 | + | s.Write([]byte("\xe4\xbd\xa0")) // 你 | |||
| 1128 | + | ||||
| 1129 | + | d := s.Damage() | |||
| 1130 | + | if len(d) == 0 { | |||
| 1131 | + | t.Fatal("expected damage") | |||
| 1132 | + | } | |||
| 1133 | + | // After normalization, damage width should be 1, not 2. | |||
| 1134 | + | if d[0].Width != 1 { | |||
| 1135 | + | t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width) | |||
| 1136 | + | } | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1139 | + | func TestEmojiDamageWidthNormalized(t *testing.T) { | |||
| 1140 | + | s := NewScreen(10, 3) | |||
| 1141 | + | s.ClearDamage() | |||
| 1142 | + | s.Write([]byte("\U0001f600")) // 😀 | |||
| 1143 | + | ||||
| 1144 | + | d := s.Damage() | |||
| 1145 | + | if len(d) == 0 { | |||
| 1146 | + | t.Fatal("expected damage") | |||
| 1147 | + | } | |||
| 1148 | + | if d[0].Width != 1 { | |||
| 1149 | + | t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width) | |||
| 1150 | + | } | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1153 | + | func TestMixedCursorAlignment(t *testing.T) { | |||
| 1154 | + | s := NewScreen(10, 3) | |||
| 1155 | + | // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́' | |||
| 1156 | + | s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81")) | |||
| 1157 | + | ||||
| 1158 | + | assertCell(t, s, 0, 0, 'a') // 'a' | |||
| 1159 | + | assertCell(t, s, 1, 0, '?') // 你 normalized | |||
| 1160 | + | assertCell(t, s, 2, 0, '?') // 😀 normalized | |||
| 1161 | + | assertCell(t, s, 3, 0, 'b') // 'b' | |||
| 1162 | + | // Combining mark skipped, no cell at col 4. | |||
| 1163 | + | // Cursor at col 4 (advanced by a, CJK, emoji, b). | |||
| 1164 | + | if s.Col != 4 || s.Row != 0 { | |||
| 1165 | + | t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row) | |||
| 1166 | + | } | |||
| 1167 | + | } | |||
| 1168 | + | ||||
| 1169 | + | func TestCombiningMarkNoCellWritten(t *testing.T) { | |||
| 1170 | + | s := NewScreen(10, 3) | |||
| 1171 | + | s.ClearDamage() | |||
| 1172 | + | s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char) | |||
| 1173 | + | ||||
| 1174 | + | // No cells should be modified. | |||
| 1175 | + | for y := 0; y < 3; y++ { | |||
| 1176 | + | for x := 0; x < 10; x++ { | |||
| 1177 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 1178 | + | t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune) | |||
| 1179 | + | } | |||
| 1180 | + | } | |||
| 1181 | + | } | |||
| 1182 | + | if s.Col != 0 || s.Row != 0 { | |||
| 1183 | + | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |||
| 1184 | + | } | |||
| 1185 | + | d := s.Damage() | |||
| 1186 | + | if len(d) != 0 { | |||
| 1187 | + | t.Errorf("expected no damage for combining mark alone, got %+v", d) | |||
| 1188 | + | } | |||
| 1189 | + | } | |||
| 1190 | + | ||||
| 1191 | + | // --------------------------------------------------------------------------- | |||
| 1192 | + | // CSI S / SU — Scroll Up | |||
| 1193 | + | // --------------------------------------------------------------------------- | |||
| 1194 | + | ||||
| 1195 | + | func TestCSI_SCrollUpDefault(t *testing.T) { | |||
| 1196 | + | s := NewScreen(5, 4) | |||
| 1197 | + | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |||
| 1198 | + | s.ClearDamage() | |||
| 1199 | + | ||||
| 1200 | + | // CSI S with no param → scroll up by 1. | |||
| 1201 | + | s.Write([]byte("\x1b[S")) | |||
| 1202 | + | ||||
| 1203 | + | // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank. | |||
| 1204 | + | assertCell(t, s, 0, 0, 'B') | |||
| 1205 | + | assertCell(t, s, 0, 1, 'C') | |||
| 1206 | + | assertCell(t, s, 0, 2, 'D') | |||
| 1207 | + | assertCell(t, s, 0, 3, ' ') | |||
| 1208 | + | ||||
| 1209 | + | // Cursor should NOT have moved. | |||
| 1210 | + | if s.Col != 5 || s.Row != 3 { | |||
| 1211 | + | t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row) | |||
| 1212 | + | } | |||
| 1213 | + | ||||
| 1214 | + | d := s.Damage() | |||
| 1215 | + | if !hasDamageKind(d, renderer.DamageScrollUp) { | |||
| 1216 | + | t.Errorf("expected DamageScrollUp, got %v", damageKinds(d)) | |||
| 1217 | + | } | |||
| 1218 | + | } | |||
| 1219 | + | ||||
| 1220 | + | func TestCSI_SCrollUpWithCount(t *testing.T) { | |||
| 1221 | + | s := NewScreen(5, 4) | |||
| 1222 | + | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |||
| 1223 | + | s.ClearDamage() | |||
| 1224 | + | ||||
| 1225 | + | // CSI 2 S → scroll up by 2. | |||
| 1226 | + | s.Write([]byte("\x1b[2S")) | |||
| 1227 | + | ||||
| 1228 | + | // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank. | |||
| 1229 | + | assertCell(t, s, 0, 0, 'C') | |||
| 1230 | + | assertCell(t, s, 0, 1, 'D') | |||
| 1231 | + | assertCell(t, s, 0, 2, ' ') | |||
| 1232 | + | assertCell(t, s, 0, 3, ' ') | |||
| 1233 | + | ||||
| 1234 | + | // Cursor preserved. | |||
| 1235 | + | if s.Col != 5 || s.Row != 3 { | |||
| 1236 | + | t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row) | |||
| 1237 | + | } | |||
| 1238 | + | } | |||
| 1239 | + | ||||
| 1240 | + | func TestCSI_SCrollUpClamp(t *testing.T) { | |||
| 1241 | + | s := NewScreen(5, 3) | |||
| 1242 | + | s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows | |||
| 1243 | + | s.ClearDamage() | |||
| 1244 | + | ||||
| 1245 | + | // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank). | |||
| 1246 | + | s.Write([]byte("\x1b[100S")) | |||
| 1247 | + | ||||
| 1248 | + | // All rows should be blank. | |||
| 1249 | + | for y := 0; y < 3; y++ { | |||
| 1250 | + | for x := 0; x < 5; x++ { | |||
| 1251 | + | assertCell(t, s, x, y, ' ') | |||
| 1252 | + | } | |||
| 1253 | + | } | |||
| 1254 | + | } | |||
| 1255 | + | ||||
| 1256 | + | func TestCSI_SCrollUpCountZero(t *testing.T) { | |||
| 1257 | + | s := NewScreen(5, 3) | |||
| 1258 | + | s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows | |||
| 1259 | + | s.ClearDamage() | |||
| 1260 | + | ||||
| 1261 | + | // CSI 0 S → default to 1 (parameter 0 means default in VT spec). | |||
| 1262 | + | s.Write([]byte("\x1b[0S")) | |||
| 1263 | + | ||||
| 1264 | + | // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank. | |||
| 1265 | + | assertCell(t, s, 0, 0, 'B') | |||
| 1266 | + | assertCell(t, s, 0, 1, 'C') | |||
| 1267 | + | assertCell(t, s, 0, 2, ' ') | |||
| 1268 | + | } | |||
| 1269 | + | ||||
| 1270 | + | func TestCSI_SCrollUpDamageCount(t *testing.T) { | |||
| 1271 | + | s := NewScreen(5, 4) | |||
| 1272 | + | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |||
| 1273 | + | s.ClearDamage() | |||
| 1274 | + | ||||
| 1275 | + | s.Write([]byte("\x1b[3S")) // scroll up by 3 | |||
| 1276 | + | ||||
| 1277 | + | d := s.Damage() | |||
| 1278 | + | var scrollDamage *renderer.Damage | |||
| 1279 | + | for i, dd := range d { | |||
| 1280 | + | if dd.Kind == renderer.DamageScrollUp { | |||
| 1281 | + | scrollDamage = &d[i] | |||
| 1282 | + | break | |||
| 1283 | + | } | |||
| 1284 | + | } | |||
| 1285 | + | if scrollDamage == nil { | |||
| 1286 | + | t.Fatal("expected DamageScrollUp") | |||
| 1287 | + | } | |||
| 1288 | + | if scrollDamage.Count != 3 { | |||
| 1289 | + | t.Errorf("scroll count = %d, want 3", scrollDamage.Count) | |||
| 1290 | + | } | |||
| 1291 | + | if scrollDamage.Width != 5 || scrollDamage.Height != 4 { | |||
| 1292 | + | t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height) | |||
| 1293 | + | } | |||
| 1294 | + | ||||
| 1295 | + | // Should also have text damage for the blanked rows (bottom 3 rows). | |||
| 1296 | + | if !hasDamageKind(d, renderer.DamageText) { | |||
| 1297 | + | t.Error("expected DamageText for blanked rows") | |||
| 1298 | + | } | |||
| 1299 | + | } | |||
| 1300 | + | ||||
| 1301 | + | func TestCSI_SCursorPreserved(t *testing.T) { | |||
| 1302 | + | s := NewScreen(10, 5) | |||
| 1303 | + | s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows | |||
| 1304 | + | s.ClearDamage() | |||
| 1305 | + | ||||
| 1306 | + | // Move cursor to middle of row 2. | |||
| 1307 | + | s.Write([]byte("\x1b[3;3H")) // row=3, col=3 | |||
| 1308 | + | s.Write([]byte("\x1b[2S")) // scroll up by 2 | |||
| 1309 | + | ||||
| 1310 | + | // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2). | |||
| 1311 | + | if s.Row != 2 || s.Col != 2 { | |||
| 1312 | + | t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col) | |||
| 1313 | + | } | |||
| 1314 | + | } | |||
| 1315 | + | ||||
| 1316 | + | // --------------------------------------------------------------------------- | |||
| 1317 | + | // OSC sequences | |||
| 1318 | + | // --------------------------------------------------------------------------- | |||
| 1319 | + | ||||
| 1320 | + | func TestOSC_BELTerminatedIgnored(t *testing.T) { | |||
| 1321 | + | s := NewScreen(20, 2) | |||
| 1322 | + | s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ ")) | |||
| 1323 | + | ||||
| 1324 | + | assertCell(t, s, 0, 0, 'f') | |||
| 1325 | + | assertCell(t, s, 1, 0, 'i') | |||
| 1326 | + | assertCell(t, s, 2, 0, 's') | |||
| 1327 | + | assertCell(t, s, 3, 0, 'h') | |||
| 1328 | + | assertCell(t, s, 4, 0, '$') | |||
| 1329 | + | assertCell(t, s, 5, 0, ' ') | |||
| 1330 | + | if s.Col != 6 || s.Row != 0 { | |||
| 1331 | + | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 1332 | + | } | |||
| 1333 | + | } | |||
| 1334 | + | ||||
| 1335 | + | func TestOSC_STTerminatedIgnored(t *testing.T) { | |||
| 1336 | + | s := NewScreen(20, 2) | |||
| 1337 | + | s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> ")) | |||
| 1338 | + | ||||
| 1339 | + | assertCell(t, s, 0, 0, '>') | |||
| 1340 | + | assertCell(t, s, 1, 0, ' ') | |||
| 1341 | + | if s.Col != 2 || s.Row != 0 { | |||
| 1342 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 1343 | + | } | |||
| 1344 | + | } | |||
| 1345 | + | ||||
| 1346 | + | func TestOSC_MultipleSequencesIgnored(t *testing.T) { | |||
| 1347 | + | s := NewScreen(20, 2) | |||
| 1348 | + | s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt")) | |||
| 1349 | + | ||||
| 1350 | + | assertCell(t, s, 0, 0, 'p') | |||
| 1351 | + | assertCell(t, s, 1, 0, 'r') | |||
| 1352 | + | assertCell(t, s, 2, 0, 'o') | |||
| 1353 | + | assertCell(t, s, 3, 0, 'm') | |||
| 1354 | + | assertCell(t, s, 4, 0, 'p') | |||
| 1355 | + | assertCell(t, s, 5, 0, 't') | |||
| 1356 | + | if s.Col != 6 || s.Row != 0 { | |||
| 1357 | + | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 1358 | + | } | |||
| 1359 | + | } | |||
| 1360 | + | ||||
| 1361 | + | func TestOSC_BELTerminatedSplitAcrossWritesIgnored(t *testing.T) { | |||
| 1362 | + | s := NewScreen(20, 2) | |||
| 1363 | + | s.Write([]byte("\x1b]0;~/Projects/ymux")) | |||
| 1364 | + | s.Write([]byte(" - fish\x07fish$ ")) | |||
| 1365 | + | ||||
| 1366 | + | assertCell(t, s, 0, 0, 'f') | |||
| 1367 | + | assertCell(t, s, 1, 0, 'i') | |||
| 1368 | + | assertCell(t, s, 2, 0, 's') | |||
| 1369 | + | assertCell(t, s, 3, 0, 'h') | |||
| 1370 | + | assertCell(t, s, 4, 0, '$') | |||
| 1371 | + | assertCell(t, s, 5, 0, ' ') | |||
| 1372 | + | if s.Col != 6 || s.Row != 0 { | |||
| 1373 | + | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 1374 | + | } | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1377 | + | func TestOSC_STTerminatedSplitAcrossWritesIgnored(t *testing.T) { | |||
| 1378 | + | s := NewScreen(20, 2) | |||
| 1379 | + | s.Write([]byte("\x1b]133;A;click_events=1\x1b")) | |||
| 1380 | + | s.Write([]byte("\\> ")) | |||
| 1381 | + | ||||
| 1382 | + | assertCell(t, s, 0, 0, '>') | |||
| 1383 | + | assertCell(t, s, 1, 0, ' ') | |||
| 1384 | + | if s.Col != 2 || s.Row != 0 { | |||
| 1385 | + | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 1386 | + | } | |||
| 1387 | + | } | |||
| 1388 | + | ||||
| 1389 | + | func TestOSC_UnterminatedSequenceIsBoundedAndDropped(t *testing.T) { | |||
| 1390 | + | s := NewScreen(20, 2) | |||
| 1391 | + | payload := strings.Repeat("x", maxEscapeBufferLen+1) | |||
| 1392 | + | s.Write([]byte("\x1b]0;" + payload)) | |||
| 1393 | + | s.Write([]byte("OK")) | |||
| 1394 | + | ||||
| 1395 | + | assertCell(t, s, 0, 0, 'O') | |||
| 1396 | + | assertCell(t, s, 1, 0, 'K') | |||
| 1397 | + | if len(s.escapeBuf) != 0 { | |||
| 1398 | + | t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf)) | |||
| 1399 | + | } | |||
| 1400 | + | } | |||
| 1401 | + | ||||
| 1402 | + | // --------------------------------------------------------------------------- | |||
| 1403 | + | // C1 Unicode controls (U+0080-U+009F) | |||
| 1404 | + | // --------------------------------------------------------------------------- | |||
| 1405 | + | ||||
| 1406 | + | func TestC1ControlsDropped(t *testing.T) { | |||
| 1407 | + | s := NewScreen(10, 3) | |||
| 1408 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 PAD | |||
| 1409 | + | s.Write([]byte{0xC2, 0x9F}) // U+009F APC | |||
| 1410 | + | ||||
| 1411 | + | // No cells should be modified. | |||
| 1412 | + | for y := 0; y < 3; y++ { | |||
| 1413 | + | for x := 0; x < 10; x++ { | |||
| 1414 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 1415 | + | t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune) | |||
| 1416 | + | } | |||
| 1417 | + | } | |||
| 1418 | + | } | |||
| 1419 | + | // Cursor should not have moved. | |||
| 1420 | + | if s.Col != 0 || s.Row != 0 { | |||
| 1421 | + | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |||
| 1422 | + | } | |||
| 1423 | + | } | |||
| 1424 | + | ||||
| 1425 | + | func TestC1ControlsRange(t *testing.T) { | |||
| 1426 | + | s := NewScreen(10, 3) | |||
| 1427 | + | // Send all C1 control runes U+0080 through U+009F. | |||
| 1428 | + | for r := 0x80; r <= 0x9F; r++ { | |||
| 1429 | + | s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)}) | |||
| 1430 | + | } | |||
| 1431 | + | ||||
| 1432 | + | // No cells should be modified. | |||
| 1433 | + | for y := 0; y < 3; y++ { | |||
| 1434 | + | for x := 0; x < 10; x++ { | |||
| 1435 | + | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 1436 | + | t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune) | |||
| 1437 | + | } | |||
| 1438 | + | } | |||
| 1439 | + | } | |||
| 1440 | + | if s.Col != 0 || s.Row != 0 { | |||
| 1441 | + | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |||
| 1442 | + | } | |||
| 1443 | + | } | |||
| 1444 | + | ||||
| 1445 | + | func TestC1ControlsAfterPrintable(t *testing.T) { | |||
| 1446 | + | s := NewScreen(10, 3) | |||
| 1447 | + | // Printable characters should still work after C1 controls. | |||
| 1448 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |||
| 1449 | + | s.Write([]byte("AB")) | |||
| 1450 | + | s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) | |||
| 1451 | + | s.Write([]byte("CD")) | |||
| 1452 | + | ||||
| 1453 | + | assertCell(t, s, 0, 0, 'A') | |||
| 1454 | + | assertCell(t, s, 1, 0, 'B') | |||
| 1455 | + | assertCell(t, s, 2, 0, 'C') | |||
| 1456 | + | assertCell(t, s, 3, 0, 'D') | |||
| 1457 | + | if s.Col != 4 { | |||
| 1458 | + | t.Errorf("cursor at col=%d, want 4", s.Col) | |||
| 1459 | + | } | |||
| 1460 | + | } | |||
| 1461 | + | ||||
| 1462 | + | func TestC1ControlsPreserveCRLF(t *testing.T) { | |||
| 1463 | + | s := NewScreen(10, 3) | |||
| 1464 | + | // C1 controls mixed with CR/LF should preserve CR/LF behavior. | |||
| 1465 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |||
| 1466 | + | s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A | |||
| 1467 | + | s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) | |||
| 1468 | + | ||||
| 1469 | + | assertCell(t, s, 0, 0, 'B') | |||
| 1470 | + | } | |||
| 1471 | + | ||||
| 1472 | + | func TestC1ControlsPreserveLF(t *testing.T) { | |||
| 1473 | + | s := NewScreen(10, 3) | |||
| 1474 | + | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |||
| 1475 | + | s.Write([]byte("A\nB")) | |||
| 1476 | + | ||||
| 1477 | + | assertCell(t, s, 0, 0, 'A') | |||
| 1478 | + | assertCell(t, s, 1, 1, 'B') | |||
| 1479 | + | if s.Row != 1 || s.Col != 2 { | |||
| 1480 | + | t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col) | |||
| 1481 | + | } | |||
| 1482 | + | } | |||
| 1483 | + | ||||
| 1484 | + | func TestC1ControlsNoDamage(t *testing.T) { | |||
| 1485 | + | s := NewScreen(10, 3) | |||
| 1486 | + | s.ClearDamage() | |||
| 1487 | + | // C1 controls should produce no damage. | |||
| 1488 | + | s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F}) | |||
| 1489 | + | ||||
| 1490 | + | d := s.Damage() | |||
| 1491 | + | if len(d) != 0 { | |||
| 1492 | + | t.Errorf("expected no damage from C1 controls, got %+v", d) | |||
| 1493 | + | } | |||
| 1494 | + | } | |||
| 1495 | + | ||||
| 1496 | + | func TestEraseDisplayModes(t *testing.T) { | |||
| 1497 | + | s := NewScreen(5, 3) | |||
| 1498 | + | s.Write([]byte("abcdefghijklmno")) | |||
| 1499 | + | s.Write([]byte("\x1b[2;3H")) | |||
| 1500 | + | s.ClearDamage() | |||
| 1501 | + | s.Write([]byte("\x1b[J")) | |||
| 1502 | + | assertCell(t, s, 0, 0, 'a') | |||
| 1503 | + | assertCell(t, s, 1, 1, 'g') | |||
| 1504 | + | if c := cellAt(s, 2, 1); c.Rune != ' ' { | |||
| 1505 | + | t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune) | |||
| 1506 | + | } | |||
| 1507 | + | ||||
| 1508 | + | s = NewScreen(5, 3) | |||
| 1509 | + | s.Write([]byte("abcdefghijklmno")) | |||
| 1510 | + | s.Write([]byte("\x1b[2;3H")) | |||
| 1511 | + | s.Write([]byte("\x1b[1J")) | |||
| 1512 | + | if c := cellAt(s, 0, 0); c.Rune != ' ' { | |||
| 1513 | + | t.Fatalf("start-to-cursor clear left %q at start", c.Rune) | |||
| 1514 | + | } | |||
| 1515 | + | assertCell(t, s, 3, 1, 'i') | |||
| 1516 | + | } | |||
| 1517 | + | ||||
| 1518 | + | func TestEraseLineModes(t *testing.T) { | |||
| 1519 | + | s := NewScreen(5, 2) | |||
| 1520 | + | s.Write([]byte("abcde")) | |||
| 1521 | + | s.Write([]byte("\x1b[1;3H")) | |||
| 1522 | + | s.Write([]byte("\x1b[1K")) | |||
| 1523 | + | if c := cellAt(s, 0, 0); c.Rune != ' ' { | |||
| 1524 | + | t.Fatalf("line start clear left %q", c.Rune) | |||
| 1525 | + | } | |||
| 1526 | + | assertCell(t, s, 3, 0, 'd') | |||
| 1527 | + | ||||
| 1528 | + | s = NewScreen(5, 2) | |||
| 1529 | + | s.Write([]byte("abcde")) | |||
| 1530 | + | s.Write([]byte("\x1b[1;3H")) | |||
| 1531 | + | s.Write([]byte("\x1b[2K")) | |||
| 1532 | + | for x := 0; x < 5; x++ { | |||
| 1533 | + | if c := cellAt(s, x, 0); c.Rune != ' ' { | |||
| 1534 | + | t.Fatalf("line clear left %q at %d", c.Rune, x) | |||
| 1535 | + | } | |||
| 1536 | + | } | |||
| 1537 | + | } | |||