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
37// NewRenderer returns a renderer that writes to out.
38func NewRenderer(out io.Writer) *Renderer {
39 return &Renderer{out: out}
40}
41
42// Resize tells the renderer the current terminal size.
43//
44// On a real size change we also issue a clear-screen so the next Draw
45// starts from a blank slate. Without the clear, characters from the
46// old (wider) layout linger past the new right edge and rows from
47// before the new bottom hang around as garbage.
48func (r *Renderer) Resize(cols, rows int) {
49 if cols != r.cols || rows != r.rows {
50 r.cols = cols
51 r.rows = rows
52 r.prev = nil
53 if r.out != nil {
54 _, _ = io.WriteString(r.out, SeqClearScreen)
55 }
56 }
57}
58
59// Clear forces a full repaint on the next Draw and clears the screen.
60func (r *Renderer) Clear() {
61 r.prev = nil
62 _, _ = io.WriteString(r.out, SeqClearScreen)
63}
64
65// Invalidate forces a full repaint on the next Draw without clearing the
66// whole terminal first. Useful when the cached diff is unreliable but a
67// visible full-screen flash would be too distracting.
68func (r *Renderer) Invalidate() {
69 r.prev = nil
70}
71
72// Draw updates the terminal so that the visible frame ends with the
73// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
74// the lines slice indicating where to place the terminal cursor; use
75// -1 to hide it.
76// containsImageEscape reports whether the line carries an inline-image
77// escape we must repaint rather than diff against the previous frame.
78func containsImageEscape(s string) bool {
79 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
80}
81
82// truncateToWidth clips s so its on-screen width doesn't exceed cols
83// cells, preserving ANSI CSI escape sequences (which don't consume
84// cells). Lines carrying an inline-image escape are returned as-is
85// since we can't measure their painted size.
86//
87// Fast path: a byte-length <= cols is a conservative upper bound
88// guaranteeing the cell width is also <= cols, so we skip all the
89// rune-width math. That covers the vast majority of lines in a
90// transcript (narrow terminals wrap early; wide ones leave headroom).
91func truncateToWidth(s string, cols int) string {
92 if cols <= 0 || containsImageEscape(s) {
93 return s
94 }
95 if len(s) <= cols {
96 return s
97 }
98 var out strings.Builder
99 out.Grow(len(s))
100 seen := 0
101 runes := []rune(s)
102 for i := 0; i < len(runes); {
103 r := runes[i]
104 // CSI escape sequence (ESC [ ... final): zero-width.
105 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
106 out.WriteRune(r)
107 out.WriteRune(runes[i+1])
108 i += 2
109 for i < len(runes) {
110 c := runes[i]
111 out.WriteRune(c)
112 i++
113 if c >= 0x40 && c <= 0x7e {
114 break
115 }
116 }
117 continue
118 }
119 rw := runewidthRune(r)
120 if seen+rw > cols {
121 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
122 // so background colors and cleanup sequences survive.
123 for i < len(runes) {
124 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
125 out.WriteRune(runes[i])
126 out.WriteRune(runes[i+1])
127 i += 2
128 for i < len(runes) {
129 c := runes[i]
130 out.WriteRune(c)
131 i++
132 if c >= 0x40 && c <= 0x7e {
133 break
134 }
135 }
136 } else {
137 break
138 }
139 }
140 break
141 }
142 out.WriteRune(r)
143 seen += rw
144 i++
145 }
146 return out.String()
147}
148
149func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
150 if r.cols == 0 || r.rows == 0 {
151 return
152 }
153 // Bottom-align: only the last r.rows lines are visible.
154 visible := lines
155 if len(visible) > r.rows {
156 visible = visible[len(visible)-r.rows:]
157 cursorRow -= len(lines) - len(visible)
158 }
159 // Pad to r.rows with empty lines at the top. Every line is also
160 // hard-truncated to cols so the terminal never soft-wraps our output
161 // (which would push the status bar out of its row).
162 frame := make([]string, r.rows)
163 top := r.rows - len(visible)
164 for i := 0; i < top; i++ {
165 frame[i] = ""
166 }
167 for i, line := range visible {
168 frame[top+i] = truncateToWidth(line, r.cols)
169 }
170
171 var w strings.Builder
172 w.WriteString(SeqSynchronizedOn)
173 w.WriteString(SeqHideCursor)
174
175 // When inline images are in play we always full-repaint (clear
176 // screen first, then rewrite every row). Terminals manage image
177 // pixels in a layer we cannot diff against, so the per-line cache
178 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
179 // the common code path below is the fast cached diff.
180 curHasImage := false
181 curHasKittyImage := false
182 for _, l := range frame {
183 if containsImageEscape(l) {
184 curHasImage = true
185 if strings.Contains(l, "\x1b_G") {
186 curHasKittyImage = true
187 }
188 }
189 }
190 forceAll := curHasImage || r.prevHadImage
191 if forceAll {
192 w.WriteString(SeqClearScreen)
193 if curHasKittyImage {
194 // Delete previously placed kitty images once per frame,
195 // before rewriting all rows. Doing this inside each image
196 // escape makes only the last image in the frame survive.
197 w.WriteString("\x1b_Ga=d\x1b\\")
198 }
199 }
200
201 // Detect selection highlights: if the current OR previous frame
202 // has selection-background rows, force full repaint. VS Code's
203 // terminal doesn't reliably clear background colors on row
204 // overwrites, leaving ghost highlights behind.
205 hasSelection := false
206 for _, l := range frame {
207 if strings.Contains(l, "\x1b[48;5;") {
208 hasSelection = true
209 break
210 }
211 }
212 if !hasSelection && r.prev != nil {
213 for _, l := range r.prev {
214 if strings.Contains(l, "\x1b[48;5;") {
215 hasSelection = true
216 break
217 }
218 }
219 }
220
221 full := r.prev == nil || len(r.prev) != r.rows
222 for i := 0; i < r.rows; i++ {
223 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
224 w.WriteString(MoveTo(i+1, 1))
225 w.WriteString("\x1b[0m") // reset all attributes first
226 w.WriteString(SeqClearLine)
227 w.WriteString(frame[i])
228 }
229 }
230
231 if cursorRow >= 0 {
232 absRow := top + cursorRow + 1
233 absCol := cursorCol + 1
234 if absRow >= 1 && absRow <= r.rows {
235 w.WriteString(MoveTo(absRow, absCol))
236 w.WriteString(SeqShowCursor)
237 }
238 }
239 w.WriteString(SeqSynchronizedOff)
240
241 _, _ = io.WriteString(r.out, w.String())
242
243 r.prev = frame
244 r.prevHadImage = curHasImage
245 r.cursorRow = cursorRow
246 r.cursorCol = cursorCol
247}