1package renderer
2
3import (
4 "bytes"
5 "strconv"
6 "sync"
7)
8
9const maxPooledBufferCap = 1 << 20
10
11var bufferPool = sync.Pool{
12 New: func() any { return new(bytes.Buffer) },
13}
14
15type Capabilities struct {
16 SynchronizedOutput bool
17}
18
19type Renderer struct {
20 caps Capabilities
21 width int
22 height int
23 shadow []Cell
24}
25
26func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
27
28func (r *Renderer) Reset() {
29 r.width = 0
30 r.height = 0
31 r.shadow = nil
32}
33
34func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
35 if err := frame.Validate(); err != nil {
36 return nil, err
37 }
38
39 knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)
40
41 // Unchanged frame no-op: same dimensions, shadow populated, and either no
42 // damage or a structural redraw request that can be satisfied from the
43 // known terminal shadow.
44 if knownSameDimensions {
45 if len(damage) == 0 && !r.hasDirtyLines(frame) {
46 return nil, nil
47 }
48 if needsFull(damage) && !r.hasDirtyLines(frame) {
49 return nil, nil
50 }
51 }
52
53 buf := bufferPool.Get().(*bytes.Buffer)
54 buf.Reset()
55 defer putBuffer(buf)
56
57 if r.caps.SynchronizedOutput {
58 buf.WriteString(SyncStartCSI)
59 }
60
61 // Per-Draw terminal state model shared by every emission path so cursor
62 // tracking and the SGR pen persist across all rects within one Draw.
63 st := newDrawState()
64
65 if !knownSameDimensions {
66 r.writeFull(buf, frame, &st)
67 r.advanceShadow(frame)
68 if r.caps.SynchronizedOutput {
69 buf.WriteString(SyncEndCSI)
70 }
71 return copyBytes(buf), nil
72 }
73
74 if needsFull(damage) {
75 r.writeDamage(buf, frame, nil, nil, &st)
76 r.advanceShadow(frame)
77 if r.caps.SynchronizedOutput {
78 buf.WriteString(SyncEndCSI)
79 }
80 return copyBytes(buf), nil
81 }
82
83 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84 spans, full := buildDamagePlan(frame, damage, &scroll)
85 if full {
86 r.writeFull(buf, frame, &st)
87 r.advanceShadow(frame)
88 if r.caps.SynchronizedOutput {
89 buf.WriteString(SyncEndCSI)
90 }
91 return copyBytes(buf), nil
92 }
93
94 emitScrollUp(buf, scroll)
95 // emitScrollUp resets the SGR pen to default (matching st's initial
96 // pen) but leaves the cursor wherever the DECSTBM restore put it —
97 // terminal-dependent, so cursor tracking stays invalidated.
98 r.applyScroll(scroll)
99 r.emitDamageSpans(buf, frame, spans, &st)
100 r.advanceDamage(frame, damage, &scroll)
101 if r.caps.SynchronizedOutput {
102 buf.WriteString(SyncEndCSI)
103 }
104 return copyBytes(buf), nil
105 }
106
107 // Scroll damage changes rows outside the explicit text/clear rectangles.
108 // If the fast path is not safe, redraw the frame instead of applying
109 // partial damage and leaving the terminal/shadow stale.
110 if hasScrollDamage(damage) {
111 r.writeFull(buf, frame, &st)
112 r.advanceShadow(frame)
113 if r.caps.SynchronizedOutput {
114 buf.WriteString(SyncEndCSI)
115 }
116 return copyBytes(buf), nil
117 }
118
119 if r.writeDamage(buf, frame, damage, nil, &st) {
120 r.advanceShadow(frame)
121 } else {
122 r.advanceDamage(frame, damage, nil)
123 }
124 if r.caps.SynchronizedOutput {
125 buf.WriteString(SyncEndCSI)
126 }
127 return copyBytes(buf), nil
128}
129
130// copyBytes copies the buffer contents into a fresh byte slice and is used
131// to return output that is independent of the pooled scratch buffer.
132func copyBytes(buf *bytes.Buffer) []byte {
133 b := buf.Bytes()
134 out := make([]byte, len(b))
135 copy(out, b)
136 return out
137}
138
139func putBuffer(buf *bytes.Buffer) {
140 if buf.Cap() > maxPooledBufferCap {
141 return
142 }
143 bufferPool.Put(buf)
144}
145
146func (r *Renderer) hasDirtyLines(frame Frame) bool {
147 for y := range frame.Height {
148 if r.lineDirty(frame, y) {
149 return true
150 }
151 }
152 return false
153}
154
155func needsFull(damage []Damage) bool {
156 if len(damage) == 0 {
157 return false
158 }
159 for _, d := range damage {
160 if d.Kind == DamageFullRedraw {
161 return true
162 }
163 }
164 return false
165}
166
167func hasScrollDamage(damage []Damage) bool {
168 for _, d := range damage {
169 if d.Kind == DamageScrollUp {
170 return true
171 }
172 }
173 return false
174}
175
176func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
177 for y := range frame.Height {
178 r.emitSpan(out, frame, y, 0, frame.Width, st)
179 }
180 out.WriteString("\x1b[0m")
181}
182
183// writeDamage emits a canonical, bounded view of text and clear damage. It
184// reports whether the planner exceeded its budget and emitted a full redraw.
185func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
186 if len(damage) == 0 {
187 for y := range frame.Height {
188 if r.lineDirty(frame, y) {
189 r.emitSpan(out, frame, y, 0, frame.Width, st)
190 }
191 }
192 out.WriteString("\x1b[0m")
193 return false
194 }
195
196 spans, full := buildDamagePlan(frame, damage, skip)
197 if full {
198 r.writeFull(out, frame, st)
199 return true
200 }
201 r.emitDamageSpans(out, frame, spans, st)
202 return false
203}
204
205func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
206 for _, span := range spans {
207 r.emitSpan(out, frame, span.y, span.x, span.width, st)
208 }
209 out.WriteString("\x1b[0m")
210}
211
212func (r *Renderer) lineDirty(frame Frame, y int) bool {
213 // The shadow is stored in canonical (logical) row order; the frame is read
214 // through its logical accessor so the frame's physical layout is invisible.
215 start := y * frame.Width
216 row := frame.Row(y)
217 for i := range frame.Width {
218 if !r.shadow[start+i].Equal(row[i]) {
219 return true
220 }
221 }
222 return false
223}
224
225func (r *Renderer) advanceShadow(frame Frame) {
226 r.replaceShadow(frame)
227}
228
229func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
230 r.syncDamage(frame, damage, scroll)
231}
232
233func (r *Renderer) replaceShadow(frame Frame) {
234 r.width = frame.Width
235 r.height = frame.Height
236 n := len(frame.Cells)
237 if cap(r.shadow) >= n {
238 r.shadow = r.shadow[:n]
239 } else {
240 r.shadow = make([]Cell, n)
241 }
242 // Copy logical rows into canonical shadow order so the frame's line-offset
243 // rotation never leaks into the renderer's own buffer.
244 for y := range frame.Height {
245 copy(r.shadow[y*frame.Width:], frame.Row(y))
246 }
247}
248
249func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) {
250 if len(damage) == 0 {
251 r.replaceShadow(frame)
252 return
253 }
254 if scroll != nil {
255 r.syncRect(frame, scroll.X, scroll.Y+scroll.Height-scroll.Count, scroll.Width, scroll.Count)
256 }
257 for _, d := range damage {
258 switch d.Kind {
259 case DamageText, DamageClear:
260 r.syncRect(frame, d.X, d.Y, d.Width, d.Height)
261 }
262 }
263}
264
265func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
266 x, y, width, height, ok := clampRect(frame, x, y, width, height)
267 if !ok {
268 return
269 }
270 for row := y; row < y+height; row++ {
271 start := row*frame.Width + x
272 frameRow := frame.Row(row)
273 copy(r.shadow[start:start+width], frameRow[x:x+width])
274 }
275}
276
277func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
278 x, width, okX := clampRange(x, width, frame.Width)
279 y, height, okY := clampRange(y, height, frame.Height)
280 if !okX || !okY {
281 return 0, 0, 0, 0, false
282 }
283 return x, y, width, height, true
284}
285
286// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
287// overflowing endpoint from untrusted damage coordinates.
288func clampRange(pos, size, limit int) (int, int, bool) {
289 if size <= 0 || limit <= 0 || pos >= limit {
290 return 0, 0, false
291 }
292 if pos < 0 {
293 // -size is safe because size is positive. If pos is at or before that
294 // point, the rectangle ends at or before zero.
295 if pos <= -size {
296 return 0, 0, false
297 }
298 end := pos + size // pos > -size proves this addition cannot overflow.
299 if end > limit {
300 end = limit
301 }
302 return 0, end, true
303 }
304
305 available := limit - pos
306 if size > available {
307 size = available
308 }
309 return pos, size, true
310}
311
312// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
313// allocations. It uses a stack-allocated buffer for integer formatting.
314func writeCursor(out *bytes.Buffer, y, x int) {
315 out.WriteString("\x1b[")
316 var b [16]byte
317 n := strconv.AppendInt(b[:0], int64(y+1), 10)
318 out.Write(n)
319 out.WriteByte(';')
320 n = strconv.AppendInt(b[:0], int64(x+1), 10)
321 out.Write(n)
322 out.WriteByte('H')
323}
324
325// writeStyle emits SGR style parameters without fmt.Fprintf allocations.
326func writeStyle(out *bytes.Buffer, style Style) {
327 out.WriteString("\x1b[0")
328 if style.Bold {
329 out.WriteString(";1")
330 }
331 if style.Attrs&AttrDim != 0 {
332 out.WriteString(";2")
333 }
334 if style.Italic {
335 out.WriteString(";3")
336 }
337 if style.Attrs&AttrUnderline != 0 {
338 switch style.UnderlineStyle {
339 case UnderlineDouble:
340 out.WriteString(";21")
341 case UnderlineCurly:
342 out.WriteString(";4:3")
343 case UnderlineDotted:
344 out.WriteString(";4:4")
345 case UnderlineDashed:
346 out.WriteString(";4:5")
347 default:
348 out.WriteString(";4")
349 }
350 }
351 if style.Attrs&AttrBlink != 0 {
352 out.WriteString(";5")
353 }
354 if style.Inverse {
355 out.WriteString(";7")
356 }
357 if style.Attrs&AttrStrikethrough != 0 {
358 out.WriteString(";9")
359 }
360 var b [16]byte
361 if style.HasForegroundRGB {
362 out.WriteString(";38;2;")
363 writeRGB(out, &b, style.ForegroundRGB)
364 } else if style.Foreground >= 0 {
365 out.WriteString(";38;5;")
366 n := strconv.AppendInt(b[:0], int64(style.Foreground), 10)
367 out.Write(n)
368 }
369 if style.HasBackgroundRGB {
370 out.WriteString(";48;2;")
371 writeRGB(out, &b, style.BackgroundRGB)
372 } else if style.Background >= 0 {
373 out.WriteString(";48;5;")
374 n := strconv.AppendInt(b[:0], int64(style.Background), 10)
375 out.Write(n)
376 }
377 if style.HasUnderlineColorRGB {
378 out.WriteString(";58;2;")
379 writeRGB(out, &b, style.UnderlineColorRGB)
380 } else if style.HasUnderlineColor {
381 out.WriteString(";58;5;")
382 n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
383 out.Write(n)
384 }
385 out.WriteByte('m')
386}
387
388func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) {
389 n := strconv.AppendInt(b[:0], int64(rgb.R), 10)
390 out.Write(n)
391 out.WriteByte(';')
392 n = strconv.AppendInt(b[:0], int64(rgb.G), 10)
393 out.Write(n)
394 out.WriteByte(';')
395 n = strconv.AppendInt(b[:0], int64(rgb.B), 10)
396 out.Write(n)
397}
398
399func sameDamage(a, b Damage) bool {
400 return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count
401}