detached 538f8cc

perf(renderer): cursor-motion, SGR, and native-clear output minimization

Draw now threads a per-Draw drawState (tracked cursor + SGR pen) through
every emission path, applying three output minimizations from NeoVim's
tui.c:

- cursor motion: moveTo picks the cheapest correct sequence by comparing
  actual encoded byte lengths (nothing / CR / CUU / CUD / CUF / CUB /
  absolute CUP); relative forms are used only while the tracked position
  is certain. Runs touching the last column invalidate tracking instead
  of modeling wrap-pending state, and damage rects that split a wide
  pair also invalidate (terminal advance no longer equals span width).
- SGR: the pen persists across all rects within one Draw; a style change
  is emitted only on transitions. Every emitting Draw still ends with a
  single trailing SGR reset, which is what lets the next Draw assume a
  default pen.
- native clears: a trailing blank run reaching the right edge becomes EL
  when not longer than the spaces it replaces (ties prefer EL: it keeps
  the cursor certain); an entirely blank line may use a vertical-only
  move plus 2K when cheaper. Both require the default pen (BCE).

Golden byte tests lock exact output per technique, including wide-rune
advance (2 cols/rune) and right-edge adversarial cases. Benchmarks gain
an outbytes/op metric: scroll fast path drops 153 to 51 bytes/op; the
full-frame draw (no blanks, no savings possible) stays byte-identical
at neutral ns/op.
commit
538f8cc03e8443efb9239ce290466503f1d82b31
author
brice · · 2026-07-02 10:53 UTC
parent
7a972e7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +531 -34
M pkg/renderer/benchmark_test.go +6 -0
@@ -16,6 +16,7 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) {
1616 r := New(Capabilities{})
1717 damage := []Damage{FullRedraw()}
1818 b.ReportAllocs()
19+ var outBytes int64
1920 for b.Loop() {
2021 r.Reset()
2122 out, err := r.Draw(frame, damage)
@@ -25,7 +26,9 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) {
2526 if len(out) == 0 {
2627 b.Fatal("expected renderer output")
2728 }
29+ outBytes += int64(len(out))
2830 }
31+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
2932 }
3033
3134 func BenchmarkRendererScrollFastPath(b *testing.B) {
@@ -50,6 +53,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
5053 }
5154
5255 b.ReportAllocs()
56+ var outBytes int64
5357 for b.Loop() {
5458 r.replaceShadow(frame)
5559 out, err := r.Draw(scrolled, damage)
@@ -59,5 +63,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
5963 if len(out) == 0 {
6064 b.Fatal("expected renderer output")
6165 }
66+ outBytes += int64(len(out))
6267 }
68+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
6369 }
@@ -16,6 +16,7 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) {
16 r := New(Capabilities{})16 r := New(Capabilities{})
17 damage := []Damage{FullRedraw()}17 damage := []Damage{FullRedraw()}
18 b.ReportAllocs()18 b.ReportAllocs()
19+ var outBytes int64
19 for b.Loop() {20 for b.Loop() {
20 r.Reset()21 r.Reset()
21 out, err := r.Draw(frame, damage)22 out, err := r.Draw(frame, damage)
@@ -25,7 +26,9 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) {
25 if len(out) == 0 {26 if len(out) == 0 {
26 b.Fatal("expected renderer output")27 b.Fatal("expected renderer output")
27 }28 }
29+ outBytes += int64(len(out))
28 }30 }
31+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
29 }32 }
30 33
31 func BenchmarkRendererScrollFastPath(b *testing.B) {34 func BenchmarkRendererScrollFastPath(b *testing.B) {
@@ -50,6 +53,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
50 }53 }
51 54
52 b.ReportAllocs()55 b.ReportAllocs()
56+ var outBytes int64
53 for b.Loop() {57 for b.Loop() {
54 r.replaceShadow(frame)58 r.replaceShadow(frame)
55 out, err := r.Draw(scrolled, damage)59 out, err := r.Draw(scrolled, damage)
@@ -59,5 +63,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
59 if len(out) == 0 {63 if len(out) == 0 {
60 b.Fatal("expected renderer output")64 b.Fatal("expected renderer output")
61 }65 }
66+ outBytes += int64(len(out))
62 }67 }
68+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
63 }69 }
A pkg/renderer/draw_state.go +250 -0
@@ -0,0 +1,250 @@
1+package renderer
2+
3+import (
4+ "bytes"
5+ "strconv"
6+)
7+
8+// drawState carries the per-Draw terminal state model: the cursor position we
9+// last put the terminal at (when it is known with certainty) and the SGR pen
10+// currently active. It lives for exactly one Draw call: between Draws the
11+// terminal may be moved by other writers, so cursor tracking never persists
12+// (this also trivially satisfies "invalidate on Reset"). The pen starts as
13+// the default style because every Draw that emits output ends with \x1b[0m,
14+// which is this package's output contract.
15+type drawState struct {
16+ curKnown bool
17+ curRow int
18+ curCol int
19+ pen Style
20+}
21+
22+func newDrawState() drawState {
23+ return drawState{pen: DefaultStyle()}
24+}
25+
26+func (st *drawState) setCursor(row, col int) {
27+ st.curKnown = true
28+ st.curRow = row
29+ st.curCol = col
30+}
31+
32+// invalidateCursor marks the tracked position as uncertain. It is called
33+// whenever an emission leaves the cursor in a terminal-dependent state, most
34+// importantly after writing into the last column: terminals differ on
35+// wrap-pending behavior, so we never model it and fall back to absolute CUP.
36+func (st *drawState) invalidateCursor() {
37+ st.curKnown = false
38+}
39+
40+// Cursor-motion candidates considered by planMove. Absolute CUP is always
41+// correct; the others are only valid relative to a certain tracked position.
42+const (
43+ moveCUP = 'H' // \x1b[<r>;<c>H
44+ moveCR = '\r' // carriage return: same row, column 0
45+ moveCUU = 'A' // \x1b[<n>A: up, same column
46+ moveCUD = 'B' // \x1b[<n>B: down, same column
47+ moveCUF = 'C' // \x1b[<n>C: forward, same row
48+ moveCUB = 'D' // \x1b[<n>D: backward, same row
49+)
50+
51+// planMove picks the cheapest correct sequence to move from the tracked
52+// position to (row, col). "Cheapest" is decided by comparing the actual
53+// encoded byte lengths of every applicable candidate (no fixed thresholds):
54+// CUP costs 4 + digits(row+1) + digits(col+1); CR costs 1; CUU/CUD/CUF/CUB
55+// cost 3 for a delta of 1 (count omitted) and 3 + digits(n) otherwise.
56+// Relative candidates are considered only when the tracked position is
57+// certain; ties go to absolute CUP, the safest form.
58+func (st *drawState) planMove(row, col int) (op byte, n, cost int) {
59+ op, n, cost = moveCUP, 0, 4+decimalLen(row+1)+decimalLen(col+1)
60+ if !st.curKnown {
61+ return op, n, cost
62+ }
63+ consider := func(candOp byte, candN, candCost int) {
64+ if candCost < cost {
65+ op, n, cost = candOp, candN, candCost
66+ }
67+ }
68+ if row == st.curRow {
69+ if col == 0 {
70+ consider(moveCR, 0, 1)
71+ }
72+ if dc := col - st.curCol; dc > 0 {
73+ consider(moveCUF, dc, csiRelLen(dc))
74+ } else if dc < 0 {
75+ consider(moveCUB, -dc, csiRelLen(-dc))
76+ }
77+ } else if col == st.curCol {
78+ if dr := row - st.curRow; dr > 0 {
79+ consider(moveCUD, dr, csiRelLen(dr))
80+ } else {
81+ consider(moveCUU, -dr, csiRelLen(-dr))
82+ }
83+ }
84+ return op, n, cost
85+}
86+
87+// moveTo emits the cheapest correct cursor-positioning sequence for (row, col)
88+// and updates tracking. Moving to the already-tracked position emits nothing.
89+func (st *drawState) moveTo(out *bytes.Buffer, row, col int) {
90+ if st.curKnown && st.curRow == row && st.curCol == col {
91+ return
92+ }
93+ op, n, _ := st.planMove(row, col)
94+ switch op {
95+ case moveCUP:
96+ writeCursor(out, row, col)
97+ case moveCR:
98+ out.WriteByte('\r')
99+ default:
100+ writeCSIRel(out, n, op)
101+ }
102+ st.setCursor(row, col)
103+}
104+
105+// moveCost returns the encoded length moveTo would emit for (row, col).
106+func (st *drawState) moveCost(row, col int) int {
107+ if st.curKnown && st.curRow == row && st.curCol == col {
108+ return 0
109+ }
110+ _, _, cost := st.planMove(row, col)
111+ return cost
112+}
113+
114+// setPen emits an SGR sequence if style differs from the current pen.
115+func (st *drawState) setPen(out *bytes.Buffer, style Style) {
116+ if style.Equal(st.pen) {
117+ return
118+ }
119+ writeStyle(out, style)
120+ st.pen = style
121+}
122+
123+// isBlank reports whether a cell renders as a default-style space. This is
124+// the differ's notion of blank: rune ' ' or 0, default style, and not the
125+// continuation half of a wide pair.
126+func isBlank(c Cell) bool {
127+ return !c.Continuation && (c.Rune == ' ' || c.Rune == 0) && c.Style.Equal(DefaultStyle())
128+}
129+
130+// Native-clear encoded lengths: EL "\x1b[K" and EL2 "\x1b[2K".
131+const (
132+ elLen = 3
133+ el2Len = 4
134+)
135+
136+// emitSpan positions the cursor and emits the cells of logical row y in
137+// [x, x+width), already clamped by the caller. It applies the three output
138+// minimizations:
139+//
140+// - cursor motion: moveTo picks the cheapest sequence, and after emission
141+// the cursor is tracked at x+width (continuation cells count as columns:
142+// the terminal advances two columns per wide rune). A span that touches
143+// the last column invalidates tracking instead — wrap-pending state is
144+// terminal-dependent and never modeled.
145+// - SGR: the pen persists across spans (st is Draw-scoped); a style change
146+// is emitted only when a cell's style differs from the pen.
147+// - native clears: a trailing run of blank cells that reaches the frame's
148+// right edge is replaced by EL when EL is not longer than the spaces it
149+// replaces (ties prefer EL: it keeps the cursor position certain). An
150+// entirely blank full-width line may instead use a vertical-only move
151+// plus 2K when that encoding is cheaper. Both clears require the default
152+// pen (BCE: terminals fill cleared cells with the pen background).
153+func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st *drawState) {
154+ end := x + width
155+ // Trailing blank suffix is EL-eligible only when the span reaches the
156+ // frame's right edge (EL clears to the end of the line, nothing less).
157+ t := end
158+ if end == frame.Width {
159+ for t > x && isBlank(frame.At(t-1, y)) {
160+ t--
161+ }
162+ }
163+ useEL := end == frame.Width && end-t >= elLen
164+ if !useEL {
165+ t = end
166+ }
167+
168+ if useEL && t == x && x == 0 && width == frame.Width {
169+ // Entire line blank: compare "move to col 0 + EL" against
170+ // "vertical-only move + 2K" (2K clears the whole line from any
171+ // column, so a cheap same-column CUD/CUU can be reused).
172+ elCost := st.moveCost(y, 0) + elLen
173+ if st.curKnown && st.moveCost(y, st.curCol)+el2Len < elCost {
174+ st.setPen(out, DefaultStyle())
175+ st.moveTo(out, y, st.curCol)
176+ out.WriteString("\x1b[2K")
177+ return
178+ }
179+ }
180+
181+ st.moveTo(out, y, x)
182+ for col := x; col < t; col++ {
183+ cell := frame.At(col, y)
184+ // Continuation cells are the right half of a wide rune. Emit nothing:
185+ // the terminal already advanced two columns for the left cell's rune.
186+ if cell.Continuation {
187+ continue
188+ }
189+ // Pen check kept inline (not via setPen): this is the hottest loop in
190+ // the renderer and the method call costs measurably on full redraws.
191+ if !cell.Style.Equal(st.pen) {
192+ writeStyle(out, cell.Style)
193+ st.pen = cell.Style
194+ }
195+ if cell.Rune == 0 {
196+ out.WriteByte(' ')
197+ } else {
198+ out.WriteRune(cell.Rune)
199+ }
200+ }
201+ if useEL {
202+ st.setPen(out, DefaultStyle())
203+ out.WriteString("\x1b[K")
204+ // EL does not move the cursor: it stays at the start of the blanks.
205+ st.setCursor(y, t)
206+ } else if end >= frame.Width {
207+ st.invalidateCursor()
208+ } else {
209+ st.setCursor(y, end)
210+ }
211+ // A span that splits a wide pair breaks the "terminal advance == span
212+ // width" assumption tracking relies on: a leading orphan continuation
213+ // (head outside the span) advances one column less, and a span ending on
214+ // a wide head whose continuation lies outside advances one more. Both are
215+ // malformed inputs the VT layer should never produce, but tracking must
216+ // stay certain, so drop it. (t is never a continuation on the EL path —
217+ // blanks are not continuations — so this check is exact for both paths.)
218+ if frame.At(x, y).Continuation || (t < frame.Width && frame.At(t, y).Continuation) {
219+ st.invalidateCursor()
220+ }
221+}
222+
223+// writeCSIRel emits a relative cursor move "\x1b[<n><letter>", omitting the
224+// count when n == 1 as the CSI default allows.
225+func writeCSIRel(out *bytes.Buffer, n int, letter byte) {
226+ out.WriteString("\x1b[")
227+ if n != 1 {
228+ var b [16]byte
229+ out.Write(strconv.AppendInt(b[:0], int64(n), 10))
230+ }
231+ out.WriteByte(letter)
232+}
233+
234+// csiRelLen is the encoded length of writeCSIRel's output.
235+func csiRelLen(n int) int {
236+ if n == 1 {
237+ return 3
238+ }
239+ return 3 + decimalLen(n)
240+}
241+
242+// decimalLen is the number of decimal digits of a non-negative int.
243+func decimalLen(n int) int {
244+ l := 1
245+ for n >= 10 {
246+ n /= 10
247+ l++
248+ }
249+ return l
250+}
@@ -0,0 +1,250 @@
1+package renderer
2+
3+import (
4+ "bytes"
5+ "strconv"
6+)
7+
8+// drawState carries the per-Draw terminal state model: the cursor position we
9+// last put the terminal at (when it is known with certainty) and the SGR pen
10+// currently active. It lives for exactly one Draw call: between Draws the
11+// terminal may be moved by other writers, so cursor tracking never persists
12+// (this also trivially satisfies "invalidate on Reset"). The pen starts as
13+// the default style because every Draw that emits output ends with \x1b[0m,
14+// which is this package's output contract.
15+type drawState struct {
16+ curKnown bool
17+ curRow int
18+ curCol int
19+ pen Style
20+}
21+
22+func newDrawState() drawState {
23+ return drawState{pen: DefaultStyle()}
24+}
25+
26+func (st *drawState) setCursor(row, col int) {
27+ st.curKnown = true
28+ st.curRow = row
29+ st.curCol = col
30+}
31+
32+// invalidateCursor marks the tracked position as uncertain. It is called
33+// whenever an emission leaves the cursor in a terminal-dependent state, most
34+// importantly after writing into the last column: terminals differ on
35+// wrap-pending behavior, so we never model it and fall back to absolute CUP.
36+func (st *drawState) invalidateCursor() {
37+ st.curKnown = false
38+}
39+
40+// Cursor-motion candidates considered by planMove. Absolute CUP is always
41+// correct; the others are only valid relative to a certain tracked position.
42+const (
43+ moveCUP = 'H' // \x1b[<r>;<c>H
44+ moveCR = '\r' // carriage return: same row, column 0
45+ moveCUU = 'A' // \x1b[<n>A: up, same column
46+ moveCUD = 'B' // \x1b[<n>B: down, same column
47+ moveCUF = 'C' // \x1b[<n>C: forward, same row
48+ moveCUB = 'D' // \x1b[<n>D: backward, same row
49+)
50+
51+// planMove picks the cheapest correct sequence to move from the tracked
52+// position to (row, col). "Cheapest" is decided by comparing the actual
53+// encoded byte lengths of every applicable candidate (no fixed thresholds):
54+// CUP costs 4 + digits(row+1) + digits(col+1); CR costs 1; CUU/CUD/CUF/CUB
55+// cost 3 for a delta of 1 (count omitted) and 3 + digits(n) otherwise.
56+// Relative candidates are considered only when the tracked position is
57+// certain; ties go to absolute CUP, the safest form.
58+func (st *drawState) planMove(row, col int) (op byte, n, cost int) {
59+ op, n, cost = moveCUP, 0, 4+decimalLen(row+1)+decimalLen(col+1)
60+ if !st.curKnown {
61+ return op, n, cost
62+ }
63+ consider := func(candOp byte, candN, candCost int) {
64+ if candCost < cost {
65+ op, n, cost = candOp, candN, candCost
66+ }
67+ }
68+ if row == st.curRow {
69+ if col == 0 {
70+ consider(moveCR, 0, 1)
71+ }
72+ if dc := col - st.curCol; dc > 0 {
73+ consider(moveCUF, dc, csiRelLen(dc))
74+ } else if dc < 0 {
75+ consider(moveCUB, -dc, csiRelLen(-dc))
76+ }
77+ } else if col == st.curCol {
78+ if dr := row - st.curRow; dr > 0 {
79+ consider(moveCUD, dr, csiRelLen(dr))
80+ } else {
81+ consider(moveCUU, -dr, csiRelLen(-dr))
82+ }
83+ }
84+ return op, n, cost
85+}
86+
87+// moveTo emits the cheapest correct cursor-positioning sequence for (row, col)
88+// and updates tracking. Moving to the already-tracked position emits nothing.
89+func (st *drawState) moveTo(out *bytes.Buffer, row, col int) {
90+ if st.curKnown && st.curRow == row && st.curCol == col {
91+ return
92+ }
93+ op, n, _ := st.planMove(row, col)
94+ switch op {
95+ case moveCUP:
96+ writeCursor(out, row, col)
97+ case moveCR:
98+ out.WriteByte('\r')
99+ default:
100+ writeCSIRel(out, n, op)
101+ }
102+ st.setCursor(row, col)
103+}
104+
105+// moveCost returns the encoded length moveTo would emit for (row, col).
106+func (st *drawState) moveCost(row, col int) int {
107+ if st.curKnown && st.curRow == row && st.curCol == col {
108+ return 0
109+ }
110+ _, _, cost := st.planMove(row, col)
111+ return cost
112+}
113+
114+// setPen emits an SGR sequence if style differs from the current pen.
115+func (st *drawState) setPen(out *bytes.Buffer, style Style) {
116+ if style.Equal(st.pen) {
117+ return
118+ }
119+ writeStyle(out, style)
120+ st.pen = style
121+}
122+
123+// isBlank reports whether a cell renders as a default-style space. This is
124+// the differ's notion of blank: rune ' ' or 0, default style, and not the
125+// continuation half of a wide pair.
126+func isBlank(c Cell) bool {
127+ return !c.Continuation && (c.Rune == ' ' || c.Rune == 0) && c.Style.Equal(DefaultStyle())
128+}
129+
130+// Native-clear encoded lengths: EL "\x1b[K" and EL2 "\x1b[2K".
131+const (
132+ elLen = 3
133+ el2Len = 4
134+)
135+
136+// emitSpan positions the cursor and emits the cells of logical row y in
137+// [x, x+width), already clamped by the caller. It applies the three output
138+// minimizations:
139+//
140+// - cursor motion: moveTo picks the cheapest sequence, and after emission
141+// the cursor is tracked at x+width (continuation cells count as columns:
142+// the terminal advances two columns per wide rune). A span that touches
143+// the last column invalidates tracking instead — wrap-pending state is
144+// terminal-dependent and never modeled.
145+// - SGR: the pen persists across spans (st is Draw-scoped); a style change
146+// is emitted only when a cell's style differs from the pen.
147+// - native clears: a trailing run of blank cells that reaches the frame's
148+// right edge is replaced by EL when EL is not longer than the spaces it
149+// replaces (ties prefer EL: it keeps the cursor position certain). An
150+// entirely blank full-width line may instead use a vertical-only move
151+// plus 2K when that encoding is cheaper. Both clears require the default
152+// pen (BCE: terminals fill cleared cells with the pen background).
153+func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st *drawState) {
154+ end := x + width
155+ // Trailing blank suffix is EL-eligible only when the span reaches the
156+ // frame's right edge (EL clears to the end of the line, nothing less).
157+ t := end
158+ if end == frame.Width {
159+ for t > x && isBlank(frame.At(t-1, y)) {
160+ t--
161+ }
162+ }
163+ useEL := end == frame.Width && end-t >= elLen
164+ if !useEL {
165+ t = end
166+ }
167+
168+ if useEL && t == x && x == 0 && width == frame.Width {
169+ // Entire line blank: compare "move to col 0 + EL" against
170+ // "vertical-only move + 2K" (2K clears the whole line from any
171+ // column, so a cheap same-column CUD/CUU can be reused).
172+ elCost := st.moveCost(y, 0) + elLen
173+ if st.curKnown && st.moveCost(y, st.curCol)+el2Len < elCost {
174+ st.setPen(out, DefaultStyle())
175+ st.moveTo(out, y, st.curCol)
176+ out.WriteString("\x1b[2K")
177+ return
178+ }
179+ }
180+
181+ st.moveTo(out, y, x)
182+ for col := x; col < t; col++ {
183+ cell := frame.At(col, y)
184+ // Continuation cells are the right half of a wide rune. Emit nothing:
185+ // the terminal already advanced two columns for the left cell's rune.
186+ if cell.Continuation {
187+ continue
188+ }
189+ // Pen check kept inline (not via setPen): this is the hottest loop in
190+ // the renderer and the method call costs measurably on full redraws.
191+ if !cell.Style.Equal(st.pen) {
192+ writeStyle(out, cell.Style)
193+ st.pen = cell.Style
194+ }
195+ if cell.Rune == 0 {
196+ out.WriteByte(' ')
197+ } else {
198+ out.WriteRune(cell.Rune)
199+ }
200+ }
201+ if useEL {
202+ st.setPen(out, DefaultStyle())
203+ out.WriteString("\x1b[K")
204+ // EL does not move the cursor: it stays at the start of the blanks.
205+ st.setCursor(y, t)
206+ } else if end >= frame.Width {
207+ st.invalidateCursor()
208+ } else {
209+ st.setCursor(y, end)
210+ }
211+ // A span that splits a wide pair breaks the "terminal advance == span
212+ // width" assumption tracking relies on: a leading orphan continuation
213+ // (head outside the span) advances one column less, and a span ending on
214+ // a wide head whose continuation lies outside advances one more. Both are
215+ // malformed inputs the VT layer should never produce, but tracking must
216+ // stay certain, so drop it. (t is never a continuation on the EL path —
217+ // blanks are not continuations — so this check is exact for both paths.)
218+ if frame.At(x, y).Continuation || (t < frame.Width && frame.At(t, y).Continuation) {
219+ st.invalidateCursor()
220+ }
221+}
222+
223+// writeCSIRel emits a relative cursor move "\x1b[<n><letter>", omitting the
224+// count when n == 1 as the CSI default allows.
225+func writeCSIRel(out *bytes.Buffer, n int, letter byte) {
226+ out.WriteString("\x1b[")
227+ if n != 1 {
228+ var b [16]byte
229+ out.Write(strconv.AppendInt(b[:0], int64(n), 10))
230+ }
231+ out.WriteByte(letter)
232+}
233+
234+// csiRelLen is the encoded length of writeCSIRel's output.
235+func csiRelLen(n int) int {
236+ if n == 1 {
237+ return 3
238+ }
239+ return 3 + decimalLen(n)
240+}
241+
242+// decimalLen is the number of decimal digits of a non-negative int.
243+func decimalLen(n int) int {
244+ l := 1
245+ for n >= 10 {
246+ n /= 10
247+ l++
248+ }
249+ return l
250+}
A pkg/renderer/output_min_test.go +259 -0
@@ -0,0 +1,259 @@
1+package renderer
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+// ---------------------------------------------------------------------------
9+// Cursor-motion minimization (drawState.moveTo)
10+// ---------------------------------------------------------------------------
11+
12+func TestMoveToChoosesCheapestSequence(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ known bool
16+ fromRow int
17+ fromCol int
18+ toRow int
19+ toCol int
20+ want string
21+ }{
22+ {name: "unknown position uses absolute CUP", known: false, toRow: 2, toCol: 3, want: "\x1b[3;4H"},
23+ {name: "same position emits nothing", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 3, want: ""},
24+ {name: "same row col0 uses CR", known: true, fromRow: 2, fromCol: 5, toRow: 2, toCol: 0, want: "\r"},
25+ {name: "same row forward uses CUF", known: true, fromRow: 2, fromCol: 2, toRow: 2, toCol: 7, want: "\x1b[5C"},
26+ {name: "same row backward uses CUB", known: true, fromRow: 2, fromCol: 7, toRow: 2, toCol: 2, want: "\x1b[5D"},
27+ {name: "same row forward by one omits count", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 4, want: "\x1b[C"},
28+ {name: "same col up uses CUU", known: true, fromRow: 5, fromCol: 3, toRow: 2, toCol: 3, want: "\x1b[3A"},
29+ {name: "same col down uses CUD", known: true, fromRow: 2, fromCol: 3, toRow: 5, toCol: 3, want: "\x1b[3B"},
30+ {name: "same col up by one omits count", known: true, fromRow: 5, fromCol: 3, toRow: 4, toCol: 3, want: "\x1b[A"},
31+ {name: "diagonal move uses absolute CUP", known: true, fromRow: 2, fromCol: 2, toRow: 5, toCol: 7, want: "\x1b[6;8H"},
32+ {name: "short forward beats absolute", known: true, fromRow: 2, fromCol: 0, toRow: 2, toCol: 1, want: "\x1b[C"},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ st := newDrawState()
38+ if tt.known {
39+ st.setCursor(tt.fromRow, tt.fromCol)
40+ }
41+ var buf bytes.Buffer
42+ st.moveTo(&buf, tt.toRow, tt.toCol)
43+ if buf.String() != tt.want {
44+ t.Errorf("moveTo() = %q, want %q", buf.String(), tt.want)
45+ }
46+ if !st.curKnown || st.curRow != tt.toRow || st.curCol != tt.toCol {
47+ t.Errorf("cursor not tracked to (%d,%d): got known=%v (%d,%d)", tt.toRow, tt.toCol, st.curKnown, st.curRow, st.curCol)
48+ }
49+ })
50+ }
51+}
52+
53+// ---------------------------------------------------------------------------
54+// End-to-end golden byte output (exact bytes emitted by Draw)
55+// ---------------------------------------------------------------------------
56+
57+func drawGolden(t *testing.T, r *Renderer, frame Frame, damage []Damage) string {
58+ t.Helper()
59+ out, err := r.Draw(frame, damage)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ return string(out)
64+}
65+
66+func setRow(f Frame, y int, s string, st Style) {
67+ col := 0
68+ for _, ru := range s {
69+ f.Set(col, y, Cell{Rune: ru, Style: st})
70+ col++
71+ }
72+}
73+
74+func TestNativeClearGolden(t *testing.T) {
75+ tests := []struct {
76+ name string
77+ build func() Frame
78+ want string
79+ }{
80+ {
81+ name: "trailing blank span becomes EL",
82+ build: func() Frame {
83+ f := NewFrame(10, 1)
84+ setRow(f, 0, "Hi", DefaultStyle())
85+ return f
86+ },
87+ // content, then EL to clear the 8 trailing blanks, then reset
88+ want: "\x1b[1;1HHi\x1b[K\x1b[0m",
89+ },
90+ {
91+ name: "whole blank line cleared natively",
92+ build: func() Frame {
93+ f := NewFrame(10, 2)
94+ setRow(f, 0, "AB", DefaultStyle())
95+ // row 1 stays entirely blank
96+ return f
97+ },
98+ // Row 0 leaves the cursor tracked at (0,2); the cheapest whole-line
99+ // clear for row 1 is a one-row CUD in the same column plus 2K
100+ // (3+4 bytes) rather than CUP to col 0 plus EL (6+3 bytes).
101+ want: "\x1b[1;1HAB\x1b[K\x1b[B\x1b[2K\x1b[0m",
102+ },
103+ {
104+ name: "short trailing blank span kept as spaces",
105+ build: func() Frame {
106+ f := NewFrame(5, 1)
107+ setRow(f, 0, "ABC", DefaultStyle())
108+ // two trailing blanks (< 3) → cheaper as spaces
109+ return f
110+ },
111+ want: "\x1b[1;1HABC \x1b[0m",
112+ },
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ r := New(Capabilities{})
118+ frame := tt.build()
119+ got := drawGolden(t, r, frame, []Damage{FullRedraw()})
120+ if got != tt.want {
121+ t.Fatalf("output = %q, want %q", got, tt.want)
122+ }
123+ // Shadow consistency: an identical redraw must be a no-op.
124+ out2, err := r.Draw(frame, nil)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ if len(out2) != 0 {
129+ t.Fatalf("second draw not a no-op: %q", string(out2))
130+ }
131+ })
132+ }
133+}
134+
135+func TestSGRPersistsAcrossDamageRects(t *testing.T) {
136+ // Two same-styled rects on different rows: the SGR sequence must be emitted
137+ // exactly once (the pen persists across rects for the whole Draw).
138+ r := New(Capabilities{})
139+ base := NewFrame(4, 3)
140+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
141+ t.Fatal(err)
142+ }
143+
144+ styled := Style{Bold: true, Foreground: 1, Background: -1}
145+ frame := NewFrame(4, 3)
146+ setRow(frame, 0, "XXXX", styled)
147+ setRow(frame, 2, "YYYY", styled)
148+ damage := []Damage{
149+ {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
150+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1, Count: 1},
151+ }
152+ got := drawGolden(t, r, frame, damage)
153+ // Row0 reaches the right edge (no trailing blanks) so tracking invalidates;
154+ // row2 is therefore positioned with an absolute CUP. The SGR appears once.
155+ want := "\x1b[1;1H\x1b[0;1;38;5;1mXXXX\x1b[3;1HYYYY\x1b[0m"
156+ if got != want {
157+ t.Fatalf("output = %q, want %q", got, want)
158+ }
159+}
160+
161+func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
162+ // Adversarial: a run printed up to the terminal's right edge leaves the
163+ // cursor in a terminal-dependent "wrap pending" state. The renderer must
164+ // NOT assume the cursor wrapped to the next row; the following row must be
165+ // positioned with an absolute CUP, never an implicit newline/relative move.
166+ r := New(Capabilities{})
167+ frame := NewFrame(5, 2)
168+ setRow(frame, 0, "ABCDE", DefaultStyle())
169+ setRow(frame, 1, "FGHIJ", DefaultStyle())
170+ got := drawGolden(t, r, frame, []Damage{FullRedraw()})
171+ want := "\x1b[1;1HABCDE\x1b[2;1HFGHIJ\x1b[0m"
172+ if got != want {
173+ t.Fatalf("output = %q, want %q", got, want)
174+ }
175+}
176+
177+func TestWideRunCursorTrackingAndEL(t *testing.T) {
178+ t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
179+ // Rect A ends with a wide pair (not at the edge); rect B starts one
180+ // column later. The gap between them must be crossed with the cursor
181+ // position that reflects the terminal advancing two columns for the
182+ // wide rune — otherwise the relative move would be wrong.
183+ r := New(Capabilities{})
184+ base := NewFrame(8, 1)
185+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
186+ t.Fatal(err)
187+ }
188+ frame := NewFrame(8, 1)
189+ frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
190+ frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
191+ frame.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
192+ frame.Set(3, 0, Cell{Rune: 'B', Style: DefaultStyle()})
193+ // column 4 stays blank (undamaged gap)
194+ frame.Set(5, 0, Cell{Rune: 'C', Style: DefaultStyle()})
195+ frame.Set(6, 0, Cell{Rune: 'D', Style: DefaultStyle()})
196+ frame.Set(7, 0, Cell{Rune: 'E', Style: DefaultStyle()})
197+ damage := []Damage{
198+ {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
199+ {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
200+ }
201+ got := drawGolden(t, r, frame, damage)
202+ // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
203+ want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
204+ if got != want {
205+ t.Fatalf("output = %q, want %q", got, want)
206+ }
207+ })
208+
209+ t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
210+ // A damage rect that ends on a wide head whose continuation lies
211+ // outside the rect makes the terminal advance one column further than
212+ // the rect width accounts for. Tracking must be dropped: the next
213+ // rect must be positioned with absolute CUP, not a relative move.
214+ r := New(Capabilities{})
215+ base := NewFrame(8, 1)
216+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
217+ t.Fatal(err)
218+ }
219+ frame := NewFrame(8, 1)
220+ frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
221+ frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
222+ frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
223+ frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
224+ damage := []Damage{
225+ // Ends on the wide head at col 1; its continuation (col 2) is outside.
226+ {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1},
227+ {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
228+ }
229+ got := drawGolden(t, r, frame, damage)
230+ // After A你 the real cursor is at col 3, not col 2: a relative CUF
231+ // would be off by one, so the second rect must use absolute CUP.
232+ want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
233+ if got != want {
234+ t.Fatalf("output = %q, want %q", got, want)
235+ }
236+ })
237+
238+ t.Run("EL after a wide pair", func(t *testing.T) {
239+ r := New(Capabilities{})
240+ frame := NewFrame(7, 1)
241+ frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
242+ frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
243+ frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
244+ frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
245+ // columns 4,5,6 blank → EL
246+ got := drawGolden(t, r, frame, []Damage{FullRedraw()})
247+ want := "\x1b[1;1H你好\x1b[K\x1b[0m"
248+ if got != want {
249+ t.Fatalf("output = %q, want %q", got, want)
250+ }
251+ out2, err := r.Draw(frame, nil)
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ if len(out2) != 0 {
256+ t.Fatalf("second draw not a no-op: %q", string(out2))
257+ }
258+ })
259+}
@@ -0,0 +1,259 @@
1+package renderer
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+// ---------------------------------------------------------------------------
9+// Cursor-motion minimization (drawState.moveTo)
10+// ---------------------------------------------------------------------------
11+
12+func TestMoveToChoosesCheapestSequence(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ known bool
16+ fromRow int
17+ fromCol int
18+ toRow int
19+ toCol int
20+ want string
21+ }{
22+ {name: "unknown position uses absolute CUP", known: false, toRow: 2, toCol: 3, want: "\x1b[3;4H"},
23+ {name: "same position emits nothing", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 3, want: ""},
24+ {name: "same row col0 uses CR", known: true, fromRow: 2, fromCol: 5, toRow: 2, toCol: 0, want: "\r"},
25+ {name: "same row forward uses CUF", known: true, fromRow: 2, fromCol: 2, toRow: 2, toCol: 7, want: "\x1b[5C"},
26+ {name: "same row backward uses CUB", known: true, fromRow: 2, fromCol: 7, toRow: 2, toCol: 2, want: "\x1b[5D"},
27+ {name: "same row forward by one omits count", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 4, want: "\x1b[C"},
28+ {name: "same col up uses CUU", known: true, fromRow: 5, fromCol: 3, toRow: 2, toCol: 3, want: "\x1b[3A"},
29+ {name: "same col down uses CUD", known: true, fromRow: 2, fromCol: 3, toRow: 5, toCol: 3, want: "\x1b[3B"},
30+ {name: "same col up by one omits count", known: true, fromRow: 5, fromCol: 3, toRow: 4, toCol: 3, want: "\x1b[A"},
31+ {name: "diagonal move uses absolute CUP", known: true, fromRow: 2, fromCol: 2, toRow: 5, toCol: 7, want: "\x1b[6;8H"},
32+ {name: "short forward beats absolute", known: true, fromRow: 2, fromCol: 0, toRow: 2, toCol: 1, want: "\x1b[C"},
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ st := newDrawState()
38+ if tt.known {
39+ st.setCursor(tt.fromRow, tt.fromCol)
40+ }
41+ var buf bytes.Buffer
42+ st.moveTo(&buf, tt.toRow, tt.toCol)
43+ if buf.String() != tt.want {
44+ t.Errorf("moveTo() = %q, want %q", buf.String(), tt.want)
45+ }
46+ if !st.curKnown || st.curRow != tt.toRow || st.curCol != tt.toCol {
47+ t.Errorf("cursor not tracked to (%d,%d): got known=%v (%d,%d)", tt.toRow, tt.toCol, st.curKnown, st.curRow, st.curCol)
48+ }
49+ })
50+ }
51+}
52+
53+// ---------------------------------------------------------------------------
54+// End-to-end golden byte output (exact bytes emitted by Draw)
55+// ---------------------------------------------------------------------------
56+
57+func drawGolden(t *testing.T, r *Renderer, frame Frame, damage []Damage) string {
58+ t.Helper()
59+ out, err := r.Draw(frame, damage)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ return string(out)
64+}
65+
66+func setRow(f Frame, y int, s string, st Style) {
67+ col := 0
68+ for _, ru := range s {
69+ f.Set(col, y, Cell{Rune: ru, Style: st})
70+ col++
71+ }
72+}
73+
74+func TestNativeClearGolden(t *testing.T) {
75+ tests := []struct {
76+ name string
77+ build func() Frame
78+ want string
79+ }{
80+ {
81+ name: "trailing blank span becomes EL",
82+ build: func() Frame {
83+ f := NewFrame(10, 1)
84+ setRow(f, 0, "Hi", DefaultStyle())
85+ return f
86+ },
87+ // content, then EL to clear the 8 trailing blanks, then reset
88+ want: "\x1b[1;1HHi\x1b[K\x1b[0m",
89+ },
90+ {
91+ name: "whole blank line cleared natively",
92+ build: func() Frame {
93+ f := NewFrame(10, 2)
94+ setRow(f, 0, "AB", DefaultStyle())
95+ // row 1 stays entirely blank
96+ return f
97+ },
98+ // Row 0 leaves the cursor tracked at (0,2); the cheapest whole-line
99+ // clear for row 1 is a one-row CUD in the same column plus 2K
100+ // (3+4 bytes) rather than CUP to col 0 plus EL (6+3 bytes).
101+ want: "\x1b[1;1HAB\x1b[K\x1b[B\x1b[2K\x1b[0m",
102+ },
103+ {
104+ name: "short trailing blank span kept as spaces",
105+ build: func() Frame {
106+ f := NewFrame(5, 1)
107+ setRow(f, 0, "ABC", DefaultStyle())
108+ // two trailing blanks (< 3) → cheaper as spaces
109+ return f
110+ },
111+ want: "\x1b[1;1HABC \x1b[0m",
112+ },
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ r := New(Capabilities{})
118+ frame := tt.build()
119+ got := drawGolden(t, r, frame, []Damage{FullRedraw()})
120+ if got != tt.want {
121+ t.Fatalf("output = %q, want %q", got, tt.want)
122+ }
123+ // Shadow consistency: an identical redraw must be a no-op.
124+ out2, err := r.Draw(frame, nil)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ if len(out2) != 0 {
129+ t.Fatalf("second draw not a no-op: %q", string(out2))
130+ }
131+ })
132+ }
133+}
134+
135+func TestSGRPersistsAcrossDamageRects(t *testing.T) {
136+ // Two same-styled rects on different rows: the SGR sequence must be emitted
137+ // exactly once (the pen persists across rects for the whole Draw).
138+ r := New(Capabilities{})
139+ base := NewFrame(4, 3)
140+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
141+ t.Fatal(err)
142+ }
143+
144+ styled := Style{Bold: true, Foreground: 1, Background: -1}
145+ frame := NewFrame(4, 3)
146+ setRow(frame, 0, "XXXX", styled)
147+ setRow(frame, 2, "YYYY", styled)
148+ damage := []Damage{
149+ {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
150+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1, Count: 1},
151+ }
152+ got := drawGolden(t, r, frame, damage)
153+ // Row0 reaches the right edge (no trailing blanks) so tracking invalidates;
154+ // row2 is therefore positioned with an absolute CUP. The SGR appears once.
155+ want := "\x1b[1;1H\x1b[0;1;38;5;1mXXXX\x1b[3;1HYYYY\x1b[0m"
156+ if got != want {
157+ t.Fatalf("output = %q, want %q", got, want)
158+ }
159+}
160+
161+func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
162+ // Adversarial: a run printed up to the terminal's right edge leaves the
163+ // cursor in a terminal-dependent "wrap pending" state. The renderer must
164+ // NOT assume the cursor wrapped to the next row; the following row must be
165+ // positioned with an absolute CUP, never an implicit newline/relative move.
166+ r := New(Capabilities{})
167+ frame := NewFrame(5, 2)
168+ setRow(frame, 0, "ABCDE", DefaultStyle())
169+ setRow(frame, 1, "FGHIJ", DefaultStyle())
170+ got := drawGolden(t, r, frame, []Damage{FullRedraw()})
171+ want := "\x1b[1;1HABCDE\x1b[2;1HFGHIJ\x1b[0m"
172+ if got != want {
173+ t.Fatalf("output = %q, want %q", got, want)
174+ }
175+}
176+
177+func TestWideRunCursorTrackingAndEL(t *testing.T) {
178+ t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
179+ // Rect A ends with a wide pair (not at the edge); rect B starts one
180+ // column later. The gap between them must be crossed with the cursor
181+ // position that reflects the terminal advancing two columns for the
182+ // wide rune — otherwise the relative move would be wrong.
183+ r := New(Capabilities{})
184+ base := NewFrame(8, 1)
185+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
186+ t.Fatal(err)
187+ }
188+ frame := NewFrame(8, 1)
189+ frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
190+ frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
191+ frame.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
192+ frame.Set(3, 0, Cell{Rune: 'B', Style: DefaultStyle()})
193+ // column 4 stays blank (undamaged gap)
194+ frame.Set(5, 0, Cell{Rune: 'C', Style: DefaultStyle()})
195+ frame.Set(6, 0, Cell{Rune: 'D', Style: DefaultStyle()})
196+ frame.Set(7, 0, Cell{Rune: 'E', Style: DefaultStyle()})
197+ damage := []Damage{
198+ {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
199+ {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
200+ }
201+ got := drawGolden(t, r, frame, damage)
202+ // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
203+ want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
204+ if got != want {
205+ t.Fatalf("output = %q, want %q", got, want)
206+ }
207+ })
208+
209+ t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
210+ // A damage rect that ends on a wide head whose continuation lies
211+ // outside the rect makes the terminal advance one column further than
212+ // the rect width accounts for. Tracking must be dropped: the next
213+ // rect must be positioned with absolute CUP, not a relative move.
214+ r := New(Capabilities{})
215+ base := NewFrame(8, 1)
216+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
217+ t.Fatal(err)
218+ }
219+ frame := NewFrame(8, 1)
220+ frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
221+ frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
222+ frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
223+ frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
224+ damage := []Damage{
225+ // Ends on the wide head at col 1; its continuation (col 2) is outside.
226+ {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1},
227+ {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
228+ }
229+ got := drawGolden(t, r, frame, damage)
230+ // After A你 the real cursor is at col 3, not col 2: a relative CUF
231+ // would be off by one, so the second rect must use absolute CUP.
232+ want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
233+ if got != want {
234+ t.Fatalf("output = %q, want %q", got, want)
235+ }
236+ })
237+
238+ t.Run("EL after a wide pair", func(t *testing.T) {
239+ r := New(Capabilities{})
240+ frame := NewFrame(7, 1)
241+ frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
242+ frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
243+ frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
244+ frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
245+ // columns 4,5,6 blank → EL
246+ got := drawGolden(t, r, frame, []Damage{FullRedraw()})
247+ want := "\x1b[1;1H你好\x1b[K\x1b[0m"
248+ if got != want {
249+ t.Fatalf("output = %q, want %q", got, want)
250+ }
251+ out2, err := r.Draw(frame, nil)
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ if len(out2) != 0 {
256+ t.Fatalf("second draw not a no-op: %q", string(out2))
257+ }
258+ })
259+}
M pkg/renderer/renderer.go +16 -34
@@ -51,8 +51,12 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
5151 buf.WriteString(SyncStartCSI)
5252 }
5353
54+ // Per-Draw terminal state model shared by every emission path so cursor
55+ // tracking and the SGR pen persist across all rects within one Draw.
56+ st := newDrawState()
57+
5458 if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) {
55- r.writeFull(buf, frame)
59+ r.writeFull(buf, frame, &st)
5660 r.replaceShadow(frame)
5761 if r.caps.SynchronizedOutput {
5862 buf.WriteString(SyncEndCSI)
@@ -62,8 +66,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
6266
6367 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
6468 emitScrollUp(buf, scroll)
69+ // emitScrollUp resets the SGR pen to default (matching st's initial
70+ // pen) but leaves the cursor wherever the DECSTBM restore put it —
71+ // terminal-dependent, so cursor tracking stays invalidated.
6572 r.applyScroll(scroll)
66- r.writeDamage(buf, frame, damage, &scroll)
73+ r.writeDamage(buf, frame, damage, &scroll, &st)
6774 r.syncDamage(frame, damage, &scroll)
6875 if r.caps.SynchronizedOutput {
6976 buf.WriteString(SyncEndCSI)
@@ -75,7 +82,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
7582 // If the fast path is not safe, redraw the frame instead of applying
7683 // partial damage and leaving the terminal/shadow stale.
7784 if hasScrollDamage(damage) {
78- r.writeFull(buf, frame)
85+ r.writeFull(buf, frame, &st)
7986 r.replaceShadow(frame)
8087 if r.caps.SynchronizedOutput {
8188 buf.WriteString(SyncEndCSI)
@@ -83,7 +90,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
8390 return copyBytes(buf), nil
8491 }
8592
86- r.writeDamage(buf, frame, damage, nil)
93+ r.writeDamage(buf, frame, damage, nil, &st)
8794 r.syncDamage(frame, damage, nil)
8895 if r.caps.SynchronizedOutput {
8996 buf.WriteString(SyncEndCSI)
@@ -137,22 +144,18 @@ func hasScrollDamage(damage []Damage) bool {
137144 return false
138145 }
139146
140-func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame) {
141- style := DefaultStyle()
147+func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
142148 for y := 0; y < frame.Height; y++ {
143- writeCursor(out, y, 0)
144- r.writeRun(out, frame, y, 0, frame.Width, &style)
149+ r.emitSpan(out, frame, y, 0, frame.Width, st)
145150 }
146151 out.WriteString("\x1b[0m")
147152 }
148153
149-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage) {
150- style := DefaultStyle()
154+func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) {
151155 if len(damage) == 0 {
152156 for y := 0; y < frame.Height; y++ {
153157 if r.lineDirty(frame, y) {
154- writeCursor(out, y, 0)
155- r.writeRun(out, frame, y, 0, frame.Width, &style)
158+ r.emitSpan(out, frame, y, 0, frame.Width, st)
156159 }
157160 }
158161 out.WriteString("\x1b[0m")
@@ -169,34 +172,13 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
169172 continue
170173 }
171174 for row := y; row < y+h; row++ {
172- writeCursor(out, row, x)
173- r.writeRun(out, frame, row, x, w, &style)
175+ r.emitSpan(out, frame, row, x, w, st)
174176 }
175177 }
176178 }
177179 out.WriteString("\x1b[0m")
178180 }
179181
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- // Continuation cells are the right half of a wide rune. Emit nothing:
184- // the terminal already advanced two columns for the left cell's rune.
185- if cell.Continuation {
186- continue
187- }
188- if !cell.Style.Equal(*style) {
189- writeStyle(out, cell.Style)
190- *style = cell.Style
191- }
192- if cell.Rune == 0 {
193- out.WriteByte(' ')
194- } else {
195- out.WriteRune(cell.Rune)
196- }
197- }
198-}
199-
200182 func (r *Renderer) lineDirty(frame Frame, y int) bool {
201183 // The shadow is stored in canonical (logical) row order; the frame is read
202184 // through its logical accessor so the frame's physical layout is invisible.
@@ -51,8 +51,12 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
51 buf.WriteString(SyncStartCSI)51 buf.WriteString(SyncStartCSI)
52 }52 }
53 53
54+ // Per-Draw terminal state model shared by every emission path so cursor
55+ // tracking and the SGR pen persist across all rects within one Draw.
56+ st := newDrawState()
57+
54 if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) {58 if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) {
55- r.writeFull(buf, frame)59+ r.writeFull(buf, frame, &st)
56 r.replaceShadow(frame)60 r.replaceShadow(frame)
57 if r.caps.SynchronizedOutput {61 if r.caps.SynchronizedOutput {
58 buf.WriteString(SyncEndCSI)62 buf.WriteString(SyncEndCSI)
@@ -62,8 +66,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
62 66
63 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {67 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
64 emitScrollUp(buf, scroll)68 emitScrollUp(buf, scroll)
69+ // emitScrollUp resets the SGR pen to default (matching st's initial
70+ // pen) but leaves the cursor wherever the DECSTBM restore put it —
71+ // terminal-dependent, so cursor tracking stays invalidated.
65 r.applyScroll(scroll)72 r.applyScroll(scroll)
66- r.writeDamage(buf, frame, damage, &scroll)73+ r.writeDamage(buf, frame, damage, &scroll, &st)
67 r.syncDamage(frame, damage, &scroll)74 r.syncDamage(frame, damage, &scroll)
68 if r.caps.SynchronizedOutput {75 if r.caps.SynchronizedOutput {
69 buf.WriteString(SyncEndCSI)76 buf.WriteString(SyncEndCSI)
@@ -75,7 +82,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
75 // If the fast path is not safe, redraw the frame instead of applying82 // If the fast path is not safe, redraw the frame instead of applying
76 // partial damage and leaving the terminal/shadow stale.83 // partial damage and leaving the terminal/shadow stale.
77 if hasScrollDamage(damage) {84 if hasScrollDamage(damage) {
78- r.writeFull(buf, frame)85+ r.writeFull(buf, frame, &st)
79 r.replaceShadow(frame)86 r.replaceShadow(frame)
80 if r.caps.SynchronizedOutput {87 if r.caps.SynchronizedOutput {
81 buf.WriteString(SyncEndCSI)88 buf.WriteString(SyncEndCSI)
@@ -83,7 +90,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
83 return copyBytes(buf), nil90 return copyBytes(buf), nil
84 }91 }
85 92
86- r.writeDamage(buf, frame, damage, nil)93+ r.writeDamage(buf, frame, damage, nil, &st)
87 r.syncDamage(frame, damage, nil)94 r.syncDamage(frame, damage, nil)
88 if r.caps.SynchronizedOutput {95 if r.caps.SynchronizedOutput {
89 buf.WriteString(SyncEndCSI)96 buf.WriteString(SyncEndCSI)
@@ -137,22 +144,18 @@ func hasScrollDamage(damage []Damage) bool {
137 return false144 return false
138 }145 }
139 146
140-func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame) {147+func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
141- style := DefaultStyle()
142 for y := 0; y < frame.Height; y++ {148 for y := 0; y < frame.Height; y++ {
143- writeCursor(out, y, 0)149+ r.emitSpan(out, frame, y, 0, frame.Width, st)
144- r.writeRun(out, frame, y, 0, frame.Width, &style)
145 }150 }
146 out.WriteString("\x1b[0m")151 out.WriteString("\x1b[0m")
147 }152 }
148 153
149-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage) {154+func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) {
150- style := DefaultStyle()
151 if len(damage) == 0 {155 if len(damage) == 0 {
152 for y := 0; y < frame.Height; y++ {156 for y := 0; y < frame.Height; y++ {
153 if r.lineDirty(frame, y) {157 if r.lineDirty(frame, y) {
154- writeCursor(out, y, 0)158+ r.emitSpan(out, frame, y, 0, frame.Width, st)
155- r.writeRun(out, frame, y, 0, frame.Width, &style)
156 }159 }
157 }160 }
158 out.WriteString("\x1b[0m")161 out.WriteString("\x1b[0m")
@@ -169,34 +172,13 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage,
169 continue172 continue
170 }173 }
171 for row := y; row < y+h; row++ {174 for row := y; row < y+h; row++ {
172- writeCursor(out, row, x)175+ r.emitSpan(out, frame, row, x, w, st)
173- r.writeRun(out, frame, row, x, w, &style)
174 }176 }
175 }177 }
176 }178 }
177 out.WriteString("\x1b[0m")179 out.WriteString("\x1b[0m")
178 }180 }
179 181
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- // Continuation cells are the right half of a wide rune. Emit nothing:
184- // the terminal already advanced two columns for the left cell's rune.
185- if cell.Continuation {
186- continue
187- }
188- if !cell.Style.Equal(*style) {
189- writeStyle(out, cell.Style)
190- *style = cell.Style
191- }
192- if cell.Rune == 0 {
193- out.WriteByte(' ')
194- } else {
195- out.WriteRune(cell.Rune)
196- }
197- }
198-}
199-
200 func (r *Renderer) lineDirty(frame Frame, y int) bool {182 func (r *Renderer) lineDirty(frame Frame, y int) bool {
201 // The shadow is stored in canonical (logical) row order; the frame is read183 // The shadow is stored in canonical (logical) row order; the frame is read
202 // through its logical accessor so the frame's physical layout is invisible.184 // through its logical accessor so the frame's physical layout is invisible.

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 2

Tags 13

↑↓ moveenter openesc close