1package tui
2
3import (
4 "io"
5 "strings"
6
7 "github.com/mattn/go-runewidth"
8)
9
10// runewidthRune reports the number of cells a rune occupies, pinned
11// here so the renderer does not depend on the editor's helper.
12func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
13
14// Renderer maintains a previous frame and writes only the lines that
15// changed on each Draw(). Callers pass a full target frame (slice of
16// styled lines, already wrapped to width).
17type Renderer struct {
18 out io.Writer
19 prev []string
20 rows int // terminal rows
21 cols int // terminal cols
22
23 // Cursor position after last draw (for placing input cursor).
24 cursorRow int
25 cursorCol int
26
27 // hideCursor when true prevents ShowCursor from being emitted.
28 hideCursor bool
29
30 // prevHadImage tracks whether the previous frame contained an
31 // inline-image escape so we can force a full clear+repaint whenever
32 // the image set changes. Only matters when inline images are
33 // enabled via ZOT_INLINE_IMAGES; defaults to false.
34 prevHadImage bool
35
36 // Main-screen flow renderer state. logLines is the full logical
37 // buffer (chat + live bottom band) from the previous DrawLog call.
38 // logViewportTop/logHardwareRow track where that logical buffer sits
39 // in the terminal's visible viewport so we can diff safely, and bail
40 // out to clear+replay when the diff would touch rows that are no
41 // longer addressable.
42 logChat []string
43 logBottom []string
44 logLines []string
45 logViewportTop int
46 logHardwareRow int
47 logInit bool
48}
49
50// NewRenderer returns a renderer that writes to out.
51func NewRenderer(out io.Writer) *Renderer {
52 return &Renderer{out: out}
53}
54
55// Resize tells the renderer the current terminal size.
56//
57// On a real size change we also issue a clear-screen so the next Draw
58// starts from a blank slate. Without the clear, characters from the
59// old (wider) layout linger past the new right edge and rows from
60// before the new bottom hang around as garbage.
61func (r *Renderer) ResetScrollRegion() {
62 if r.out != nil {
63 _, _ = io.WriteString(r.out, SeqResetScrollRegion)
64 }
65}
66
67func (r *Renderer) Resize(cols, rows int) {
68 if cols != r.cols || rows != r.rows {
69 r.cols = cols
70 r.rows = rows
71 r.prev = nil
72 r.logChat = nil
73 r.logBottom = nil
74 r.logLines = nil
75 r.logViewportTop = 0
76 r.logHardwareRow = 0
77 r.logInit = false
78 if r.out != nil {
79 // Clear both screen and scrollback so stale content from
80 // the old width doesn't bleed through. Move to (1,1) so
81 // the next DrawLog/writeFull starts from a clean slate.
82 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
83 }
84 }
85}
86
87// Clear forces a full repaint on the next Draw and clears the screen
88// plus scrollback. In main-screen flow mode this is required whenever
89// already-emitted transcript layout changes (for example ctrl+o
90// expand/collapse), because terminal scrollback cannot be edited
91// reliably once printed.
92func (r *Renderer) Clear() {
93 r.prev = nil
94 r.logChat = nil
95 r.logBottom = nil
96 r.logLines = nil
97 r.logViewportTop = 0
98 r.logHardwareRow = 0
99 r.logInit = false
100 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
101}
102
103// Invalidate forces a full repaint on the next Draw without clearing the
104// whole terminal first. Useful when the cached diff is unreliable but a
105// visible full-screen flash would be too distracting.
106func (r *Renderer) Invalidate() {
107 r.prev = nil
108 r.logLines = nil
109}
110
111// Draw updates the terminal so that the visible frame ends with the
112// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
113// the lines slice indicating where to place the terminal cursor; use
114// -1 to hide it.
115// containsImageEscape reports whether the line carries an inline-image
116// escape we must repaint rather than diff against the previous frame.
117func containsImageEscape(s string) bool {
118 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
119}
120
121// truncateToWidth clips s so its on-screen width doesn't exceed cols
122// cells, preserving ANSI CSI escape sequences (which don't consume
123// cells). Lines carrying an inline-image escape are returned as-is
124// since we can't measure their painted size.
125//
126// Fast path: a byte-length <= cols is a conservative upper bound
127// guaranteeing the cell width is also <= cols, so we skip all the
128// rune-width math. That covers the vast majority of lines in a
129// transcript (narrow terminals wrap early; wide ones leave headroom).
130func truncateToWidth(s string, cols int) string {
131 if cols <= 0 || containsImageEscape(s) {
132 return s
133 }
134 if len(s) <= cols {
135 return s
136 }
137 var out strings.Builder
138 out.Grow(len(s))
139 seen := 0
140 runes := []rune(s)
141 for i := 0; i < len(runes); {
142 r := runes[i]
143 // CSI escape sequence (ESC [ ... final): zero-width.
144 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
145 out.WriteRune(r)
146 out.WriteRune(runes[i+1])
147 i += 2
148 for i < len(runes) {
149 c := runes[i]
150 out.WriteRune(c)
151 i++
152 if c >= 0x40 && c <= 0x7e {
153 break
154 }
155 }
156 continue
157 }
158 rw := runewidthRune(r)
159 if seen+rw > cols {
160 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
161 // so background colors and cleanup sequences survive.
162 for i < len(runes) {
163 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
164 out.WriteRune(runes[i])
165 out.WriteRune(runes[i+1])
166 i += 2
167 for i < len(runes) {
168 c := runes[i]
169 out.WriteRune(c)
170 i++
171 if c >= 0x40 && c <= 0x7e {
172 break
173 }
174 }
175 } else {
176 break
177 }
178 }
179 break
180 }
181 out.WriteRune(r)
182 seen += rw
183 i++
184 }
185 return out.String()
186}
187
188func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
189 if r.cols == 0 || r.rows == 0 {
190 return
191 }
192 // Bottom-align: only the last r.rows lines are visible.
193 visible := lines
194 if len(visible) > r.rows {
195 visible = visible[len(visible)-r.rows:]
196 cursorRow -= len(lines) - len(visible)
197 }
198 // Pad to r.rows with empty lines at the top. Every line is also
199 // hard-truncated to cols so the terminal never soft-wraps our output
200 // (which would push the status bar out of its row).
201 frame := make([]string, r.rows)
202 top := r.rows - len(visible)
203 for i := 0; i < top; i++ {
204 frame[i] = ""
205 }
206 for i, line := range visible {
207 frame[top+i] = truncateToWidth(line, r.cols)
208 }
209
210 var w strings.Builder
211 w.WriteString(SeqSynchronizedOn)
212 w.WriteString(SeqHideCursor)
213
214 // When inline images are in play we always full-repaint (clear
215 // screen first, then rewrite every row). Terminals manage image
216 // pixels in a layer we cannot diff against, so the per-line cache
217 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
218 // the common code path below is the fast cached diff.
219 curHasImage := false
220 curHasKittyImage := false
221 for _, l := range frame {
222 if containsImageEscape(l) {
223 curHasImage = true
224 if strings.Contains(l, "\x1b_G") {
225 curHasKittyImage = true
226 }
227 }
228 }
229 forceAll := curHasImage || r.prevHadImage
230 if forceAll {
231 w.WriteString(SeqClearScreen)
232 if curHasKittyImage {
233 // Delete previously placed kitty images once per frame,
234 // before rewriting all rows. Doing this inside each image
235 // escape makes only the last image in the frame survive.
236 w.WriteString("\x1b_Ga=d\x1b\\")
237 }
238 }
239
240 // Detect selection highlights: if the current OR previous frame
241 // has selection-background rows, force full repaint. VS Code's
242 // terminal doesn't reliably clear background colors on row
243 // overwrites, leaving ghost highlights behind.
244 hasSelection := false
245 for _, l := range frame {
246 if strings.Contains(l, "\x1b[48;5;") {
247 hasSelection = true
248 break
249 }
250 }
251 if !hasSelection && r.prev != nil {
252 for _, l := range r.prev {
253 if strings.Contains(l, "\x1b[48;5;") {
254 hasSelection = true
255 break
256 }
257 }
258 }
259
260 full := r.prev == nil || len(r.prev) != r.rows
261 for i := 0; i < r.rows; i++ {
262 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
263 w.WriteString(MoveTo(i+1, 1))
264 w.WriteString("\x1b[0m") // reset all attributes first
265 w.WriteString(SeqClearLine)
266 w.WriteString(frame[i])
267 }
268 }
269
270 if cursorRow >= 0 {
271 absRow := top + cursorRow + 1
272 absCol := cursorCol + 1
273 if absRow >= 1 && absRow <= r.rows {
274 w.WriteString(MoveTo(absRow, absCol))
275 w.WriteString(SeqShowCursor)
276 }
277 }
278 w.WriteString(SeqSynchronizedOff)
279
280 _, _ = io.WriteString(r.out, w.String())
281
282 r.prev = frame
283 r.prevHadImage = curHasImage
284 r.cursorRow = cursorRow
285 r.cursorCol = cursorCol
286}
287
288// DrawLog renders zot in the terminal's main screen as normal terminal
289// flow rather than a fixed full-screen frame. Chat lines are emitted once
290// into the host terminal scrollback; the current bottom block (dialogs,
291// slash popup, status, editor) is erased and redrawn in place at the end.
292//
293// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
294func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
295 if r.cols == 0 || r.rows == 0 {
296 return
297 }
298 if len(bottom) == 0 {
299 bottom = []string{""}
300 }
301 chatFrame := make([]string, len(chat))
302 for i, line := range chat {
303 chatFrame[i] = truncateToWidth(line, r.cols)
304 }
305 bottomFrame := make([]string, len(bottom))
306 for i, line := range bottom {
307 bottomFrame[i] = truncateToWidth(line, r.cols)
308 }
309
310 // Always reserve one real row below the editor/status band. This is
311 // renderer-owned (not a best-effort trailing blank in the caller's
312 // bottom block), so the logical-buffer diff keeps it visible and cursor
313 // placement remains relative to the editor itself.
314 const bottomMarginRows = 1
315 lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
316 lines = append(lines, chatFrame...)
317 lines = append(lines, bottomFrame...)
318 for range bottomMarginRows {
319 lines = append(lines, "")
320 }
321 if len(lines) == 0 {
322 lines = []string{""}
323 }
324
325 cursorTargetRow := -1
326 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
327 cursorTargetRow = len(chatFrame) + cursorBottomRow
328 }
329
330 var w strings.Builder
331 w.WriteString(SeqSynchronizedOn)
332 w.WriteString(SeqHideCursor)
333
334 writeFull := func(clear bool) {
335 if clear {
336 w.WriteString(SeqDeleteKittyImages)
337 w.WriteString(SeqClearScreen)
338 w.WriteString(SeqClearScrollback)
339 w.WriteString(MoveTo(1, 1))
340 }
341 for idx, line := range lines {
342 if idx > 0 {
343 w.WriteString("\r\n")
344 }
345 w.WriteString("\x1b[0m")
346 w.WriteString(SeqClearLine)
347 w.WriteString(line)
348 }
349 r.logHardwareRow = len(lines) - 1
350 r.logViewportTop = len(lines) - r.rows
351 if r.logViewportTop < 0 {
352 r.logViewportTop = 0
353 }
354 }
355
356 moveToLogicalRow := func(targetRow int) {
357 if targetRow < 0 {
358 targetRow = 0
359 }
360 if targetRow >= len(lines) {
361 targetRow = len(lines) - 1
362 }
363 viewportBottom := r.logViewportTop + r.rows - 1
364 if targetRow > viewportBottom {
365 currentScreenRow := r.logHardwareRow - r.logViewportTop
366 if currentScreenRow < 0 {
367 currentScreenRow = 0
368 }
369 if currentScreenRow >= r.rows {
370 currentScreenRow = r.rows - 1
371 }
372 moveToBottom := r.rows - 1 - currentScreenRow
373 if moveToBottom > 0 {
374 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
375 }
376 scroll := targetRow - viewportBottom
377 for s := 0; s < scroll; s++ {
378 w.WriteString("\r\n")
379 }
380 r.logViewportTop += scroll
381 r.logHardwareRow = targetRow
382 return
383 }
384 currentScreenRow := r.logHardwareRow - r.logViewportTop
385 targetScreenRow := targetRow - r.logViewportTop
386 lineDiff := targetScreenRow - currentScreenRow
387 if lineDiff > 0 {
388 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
389 } else if lineDiff < 0 {
390 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
391 }
392 r.logHardwareRow = targetRow
393 }
394
395 positionCursor := func() {
396 if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
397 return
398 }
399 moveToLogicalRow(cursorTargetRow)
400 w.WriteString("\r")
401 if cursorCol > 0 {
402 w.WriteString("\x1b[" + itoa(cursorCol) + "C")
403 }
404 w.WriteString(SeqShowCursor)
405 }
406
407 // Selection-highlight workaround removed: it could mis-invalidate
408 // user-bubble padding rows whose colored bg made botHasHL trip,
409 // causing the next diff pass to leave those rows visually thinned
410 // because the cached entry was the \x00 sentinel rather than the
411 // real previous bg-colored row.
412
413 full := !r.logInit || len(r.logLines) == 0
414 if full {
415 writeFull(true)
416 r.logInit = true
417 } else {
418 firstChanged := -1
419 lastChanged := -1
420 maxLines := len(lines)
421 if len(r.logLines) > maxLines {
422 maxLines = len(r.logLines)
423 }
424 for idx := 0; idx < maxLines; idx++ {
425 oldLine := ""
426 if idx < len(r.logLines) {
427 oldLine = r.logLines[idx]
428 }
429 newLine := ""
430 if idx < len(lines) {
431 newLine = lines[idx]
432 }
433 if oldLine != newLine {
434 if firstChanged == -1 {
435 firstChanged = idx
436 }
437 lastChanged = idx
438 }
439 }
440 // Buffer grew but the appended rows were empty (or otherwise
441 // equal to the implicit "" past the old end). The diff above
442 // won't flag those rows, yet the renderer still needs to
443 // advance its hardware cursor / viewport tracking past them so
444 // the next render starts from the correct position. Treat the
445 // extension as changed.
446 if len(lines) > len(r.logLines) {
447 if firstChanged == -1 {
448 firstChanged = len(r.logLines)
449 }
450 if lastChanged < len(lines)-1 {
451 lastChanged = len(lines) - 1
452 }
453 }
454
455 if firstChanged == -1 {
456 // No content changes; the final cursor positioning below may still
457 // move the hardware cursor if the editor cursor changed.
458 } else if firstChanged < r.logViewportTop {
459 // Changes above the visible viewport cannot be patched safely.
460 writeFull(true)
461 } else {
462 prevViewportTop := r.logViewportTop
463 viewportTop := prevViewportTop
464 hardwareRow := r.logHardwareRow
465 prevViewportBottom := prevViewportTop + r.rows - 1
466 appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
467 moveTarget := firstChanged
468 if appendStart {
469 moveTarget = firstChanged - 1
470 }
471
472 if moveTarget > prevViewportBottom {
473 currentScreenRow := hardwareRow - prevViewportTop
474 if currentScreenRow < 0 {
475 currentScreenRow = 0
476 }
477 if currentScreenRow >= r.rows {
478 currentScreenRow = r.rows - 1
479 }
480 moveToBottom := r.rows - 1 - currentScreenRow
481 if moveToBottom > 0 {
482 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
483 }
484 scroll := moveTarget - prevViewportBottom
485 for s := 0; s < scroll; s++ {
486 w.WriteString("\r\n")
487 }
488 prevViewportTop += scroll
489 viewportTop += scroll
490 hardwareRow = moveTarget
491 }
492
493 currentScreenRow := hardwareRow - prevViewportTop
494 targetScreenRow := moveTarget - viewportTop
495 lineDiff := targetScreenRow - currentScreenRow
496 if lineDiff > 0 {
497 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
498 } else if lineDiff < 0 {
499 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
500 }
501 if appendStart {
502 w.WriteString("\r\n")
503 } else {
504 w.WriteString("\r")
505 }
506
507 renderEnd := lastChanged
508 if renderEnd >= len(lines) {
509 renderEnd = len(lines) - 1
510 }
511 for idx := firstChanged; idx <= renderEnd; idx++ {
512 if idx > firstChanged {
513 w.WriteString("\r\n")
514 }
515 w.WriteString("\x1b[0m")
516 w.WriteString(SeqClearLine)
517 w.WriteString(lines[idx])
518 }
519 finalRow := renderEnd
520 if len(r.logLines) > len(lines) {
521 extra := len(r.logLines) - len(lines)
522 if extra > r.rows {
523 writeFull(true)
524 } else {
525 for e := 0; e < extra; e++ {
526 w.WriteString("\x1b[1B")
527 w.WriteString("\r")
528 w.WriteString("\x1b[0m")
529 w.WriteString(SeqClearLine)
530 finalRow++
531 }
532 if extra > 0 {
533 w.WriteString("\x1b[" + itoa(extra) + "A")
534 finalRow -= extra
535 }
536 }
537 }
538 r.logHardwareRow = finalRow
539 r.logViewportTop = viewportTop
540 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
541 r.logViewportTop = minTop
542 }
543 if r.logViewportTop < 0 {
544 r.logViewportTop = 0
545 }
546 }
547 }
548
549 positionCursor()
550 w.WriteString(SeqSynchronizedOff)
551 _, _ = io.WriteString(r.out, w.String())
552
553 r.logChat = append(r.logChat[:0], chatFrame...)
554 r.logBottom = append(r.logBottom[:0], bottomFrame...)
555 r.logLines = append(r.logLines[:0], lines...)
556 r.cursorRow = cursorBottomRow
557 r.cursorCol = cursorCol
558}
559
560func writeBlock(w *strings.Builder, lines []string) {
561 for i, line := range lines {
562 w.WriteString("\x1b[0m")
563 w.WriteString(SeqClearLine)
564 w.WriteString(line)
565 if i < len(lines)-1 {
566 w.WriteString("\r\n")
567 }
568 }
569}
570
571func tailTruncated(lines []string, maxRows, cols int) []string {
572 if maxRows <= 0 {
573 return nil
574 }
575 if len(lines) > maxRows {
576 lines = lines[len(lines)-maxRows:]
577 }
578 out := make([]string, len(lines))
579 for i, line := range lines {
580 out[i] = truncateToWidth(line, cols)
581 }
582 return out
583}