render.go RawHistoryFolder
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.
43func (r *Renderer) Resize(cols, rows int) {
44 if cols != r.cols || rows != r.rows {
45 r.cols = cols
46 r.rows = rows
47 r.prev = nil
48 }
49}
50
51// Clear forces a full repaint on the next Draw and clears the screen.
52func (r *Renderer) Clear() {
53 r.prev = nil
54 _, _ = io.WriteString(r.out, SeqClearScreen)
55}
56
57// Draw updates the terminal so that the visible frame ends with the
58// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
59// the lines slice indicating where to place the terminal cursor; use
60// -1 to hide it.
61// containsImageEscape reports whether the line carries an inline-image
62// escape we must repaint rather than diff against the previous frame.
63func containsImageEscape(s string) bool {
64 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
65}
66
67// truncateToWidth clips s so its on-screen width doesn't exceed cols
68// cells, preserving ANSI CSI escape sequences (which don't consume
69// cells). Lines carrying an inline-image escape are returned as-is
70// since we can't measure their painted size.
71func truncateToWidth(s string, cols int) string {
72 if cols <= 0 || containsImageEscape(s) {
73 return s
74 }
75 var out strings.Builder
76 out.Grow(len(s))
77 seen := 0
78 runes := []rune(s)
79 for i := 0; i < len(runes); {
80 r := runes[i]
81 // CSI escape sequence (ESC [ ... final): zero-width.
82 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
83 out.WriteRune(r)
84 out.WriteRune(runes[i+1])
85 i += 2
86 for i < len(runes) {
87 c := runes[i]
88 out.WriteRune(c)
89 i++
90 if c >= 0x40 && c <= 0x7e {
91 break
92 }
93 }
94 continue
95 }
96 rw := runewidthRune(r)
97 if seen+rw > cols {
98 break
99 }
100 out.WriteRune(r)
101 seen += rw
102 i++
103 }
104 return out.String()
105}
106
107func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
108 if r.cols == 0 || r.rows == 0 {
109 return
110 }
111 // Bottom-align: only the last r.rows lines are visible.
112 visible := lines
113 if len(visible) > r.rows {
114 visible = visible[len(visible)-r.rows:]
115 cursorRow -= len(lines) - len(visible)
116 }
117 // Pad to r.rows with empty lines at the top. Every line is also
118 // hard-truncated to cols so the terminal never soft-wraps our output
119 // (which would push the status bar out of its row).
120 frame := make([]string, r.rows)
121 top := r.rows - len(visible)
122 for i := 0; i < top; i++ {
123 frame[i] = ""
124 }
125 for i, line := range visible {
126 frame[top+i] = truncateToWidth(line, r.cols)
127 }
128
129 var w strings.Builder
130 w.WriteString(SeqSynchronizedOn)
131 w.WriteString(SeqHideCursor)
132
133 // When inline images are in play we always full-repaint (clear
134 // screen first, then rewrite every row). Terminals manage image
135 // pixels in a layer we cannot diff against, so the per-line cache
136 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
137 // the common code path below is the fast cached diff.
138 curHasImage := false
139 for _, l := range frame {
140 if containsImageEscape(l) {
141 curHasImage = true
142 break
143 }
144 }
145 forceAll := curHasImage || r.prevHadImage
146 if forceAll {
147 w.WriteString(SeqClearScreen)
148 }
149
150 full := r.prev == nil || len(r.prev) != r.rows
151 for i := 0; i < r.rows; i++ {
152 if full || forceAll || r.prev[i] != frame[i] {
153 w.WriteString(MoveTo(i+1, 1))
154 w.WriteString(SeqClearLine)
155 w.WriteString(frame[i])
156 }
157 }
158
159 if cursorRow >= 0 {
160 absRow := top + cursorRow + 1
161 absCol := cursorCol + 1
162 if absRow >= 1 && absRow <= r.rows {
163 w.WriteString(MoveTo(absRow, absCol))
164 w.WriteString(SeqShowCursor)
165 }
166 }
167 w.WriteString(SeqSynchronizedOff)
168
169 _, _ = io.WriteString(r.out, w.String())
170
171 r.prev = frame
172 r.prevHadImage = curHasImage
173 r.cursorRow = cursorRow
174 r.cursorCol = cursorCol
175}