detached 84a83dc

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close