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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 16 | 16 | r := New(Capabilities{}) | |
| 17 | 17 | damage := []Damage{FullRedraw()} | |
| 18 | 18 | b.ReportAllocs() | |
| 19 | + | var outBytes int64 | |
| 19 | 20 | for b.Loop() { | |
| 20 | 21 | r.Reset() | |
| 21 | 22 | out, err := r.Draw(frame, damage) | |
| @@ -25,7 +26,9 @@ func BenchmarkRendererFullFrameDraw(b *testing.B) { | |||
| 25 | 26 | if len(out) == 0 { | |
| 26 | 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 | 34 | func BenchmarkRendererScrollFastPath(b *testing.B) { | |
| @@ -50,6 +53,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) { | |||
| 50 | 53 | } | |
| 51 | 54 | ||
| 52 | 55 | b.ReportAllocs() | |
| 56 | + | var outBytes int64 | |
| 53 | 57 | for b.Loop() { | |
| 54 | 58 | r.replaceShadow(frame) | |
| 55 | 59 | out, err := r.Draw(scrolled, damage) | |
| @@ -59,5 +63,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) { | |||
| 59 | 63 | if len(out) == 0 { | |
| 60 | 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 | } | |
| @@ -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) { | |||
| 51 | 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 | 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 | 60 | r.replaceShadow(frame) | |
| 57 | 61 | if r.caps.SynchronizedOutput { | |
| 58 | 62 | buf.WriteString(SyncEndCSI) | |
| @@ -62,8 +66,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 62 | 66 | ||
| 63 | 67 | if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) { | |
| 64 | 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 | 72 | r.applyScroll(scroll) | |
| 66 | - | r.writeDamage(buf, frame, damage, &scroll) | |
| 73 | + | r.writeDamage(buf, frame, damage, &scroll, &st) | |
| 67 | 74 | r.syncDamage(frame, damage, &scroll) | |
| 68 | 75 | if r.caps.SynchronizedOutput { | |
| 69 | 76 | buf.WriteString(SyncEndCSI) | |
| @@ -75,7 +82,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 75 | 82 | // If the fast path is not safe, redraw the frame instead of applying | |
| 76 | 83 | // partial damage and leaving the terminal/shadow stale. | |
| 77 | 84 | if hasScrollDamage(damage) { | |
| 78 | - | r.writeFull(buf, frame) | |
| 85 | + | r.writeFull(buf, frame, &st) | |
| 79 | 86 | r.replaceShadow(frame) | |
| 80 | 87 | if r.caps.SynchronizedOutput { | |
| 81 | 88 | buf.WriteString(SyncEndCSI) | |
| @@ -83,7 +90,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 83 | 90 | return copyBytes(buf), nil | |
| 84 | 91 | } | |
| 85 | 92 | ||
| 86 | - | r.writeDamage(buf, frame, damage, nil) | |
| 93 | + | r.writeDamage(buf, frame, damage, nil, &st) | |
| 87 | 94 | r.syncDamage(frame, damage, nil) | |
| 88 | 95 | if r.caps.SynchronizedOutput { | |
| 89 | 96 | buf.WriteString(SyncEndCSI) | |
| @@ -137,22 +144,18 @@ func hasScrollDamage(damage []Damage) bool { | |||
| 137 | 144 | return false | |
| 138 | 145 | } | |
| 139 | 146 | ||
| 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) { | |
| 142 | 148 | 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) | |
| 145 | 150 | } | |
| 146 | 151 | out.WriteString("\x1b[0m") | |
| 147 | 152 | } | |
| 148 | 153 | ||
| 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) { | |
| 151 | 155 | if len(damage) == 0 { | |
| 152 | 156 | for y := 0; y < frame.Height; y++ { | |
| 153 | 157 | 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) | |
| 156 | 159 | } | |
| 157 | 160 | } | |
| 158 | 161 | out.WriteString("\x1b[0m") | |
| @@ -169,34 +172,13 @@ func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, | |||
| 169 | 172 | continue | |
| 170 | 173 | } | |
| 171 | 174 | 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) | |
| 174 | 176 | } | |
| 175 | 177 | } | |
| 176 | 178 | } | |
| 177 | 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 | 182 | func (r *Renderer) lineDirty(frame Frame, y int) bool { | |
| 201 | 183 | // The shadow is stored in canonical (logical) row order; the frame is read | |
| 202 | 184 | // 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 applying | 82 | // 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), nil | 90 | 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 false | 144 | 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 | continue | 172 | 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 read | 183 | // 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. | ||