standard_renderer.go

  1package tea
  2
  3import (
  4	"bytes"
  5	"image/color"
  6	"io"
  7	"strings"
  8	"sync"
  9
 10	"github.com/charmbracelet/colorprofile"
 11	uv "github.com/charmbracelet/ultraviolet"
 12	"github.com/charmbracelet/x/ansi"
 13)
 14
 15// standardRenderer is a framerate-based terminal renderer, updating the view
 16// at a given framerate to avoid overloading the terminal emulator.
 17//
 18// In cases where very high performance is needed the renderer can be told
 19// to exclude ranges of lines, allowing them to be written to directly.
 20type standardRenderer struct {
 21	mtx *sync.Mutex
 22	out *colorprofile.Writer
 23
 24	buf                bytes.Buffer
 25	queuedMessageLines []string
 26	done               chan struct{}
 27	lastRender         string
 28	lastRenderedLines  []string
 29	linesRendered      int
 30	altLinesRendered   int
 31
 32	// cursor visibility state
 33	cursorHidden bool
 34
 35	// essentially whether or not we're using the full size of the terminal
 36	altScreenActive bool
 37
 38	// renderer dimensions; usually the size of the window
 39	width  int
 40	height int
 41
 42	// lines explicitly set not to render
 43	ignoreLines map[int]struct{}
 44}
 45
 46// newRenderer creates a new renderer. Normally you'll want to initialize it
 47// with os.Stdout as the first argument.
 48func newRenderer(out io.Writer) renderer {
 49	r := &standardRenderer{
 50		out: &colorprofile.Writer{
 51			Forward: out,
 52		},
 53		mtx:                &sync.Mutex{},
 54		done:               make(chan struct{}),
 55		queuedMessageLines: []string{},
 56	}
 57	return r
 58}
 59
 60// setColorProfile sets the color profile.
 61func (r *standardRenderer) setColorProfile(p colorprofile.Profile) {
 62	r.mtx.Lock()
 63	r.out.Profile = p
 64	r.mtx.Unlock()
 65}
 66
 67// reset resets the renderer to its initial state.
 68func (r *standardRenderer) reset() {
 69	// no-op
 70}
 71
 72// close closes the renderer and flushes any remaining data.
 73func (r *standardRenderer) close() error {
 74	// flush locks the mutex
 75	_ = r.flush(nil)
 76
 77	r.mtx.Lock()
 78	defer r.mtx.Unlock()
 79
 80	r.execute(ansi.EraseEntireLine)
 81	// Move the cursor back to the beginning of the line
 82	r.execute("\r")
 83
 84	return nil
 85}
 86
 87// execute writes a sequence to the terminal.
 88func (r *standardRenderer) execute(seq string) {
 89	_, _ = io.WriteString(r.out, seq)
 90}
 91
 92// writeString writes a string to the internal buffer.
 93func (r *standardRenderer) writeString(s string) (int, error) {
 94	r.mtx.Lock()
 95	defer r.mtx.Unlock()
 96
 97	return r.buf.WriteString(s)
 98}
 99
100// flush renders the buffer.
101func (r *standardRenderer) flush(*Program) error {
102	r.mtx.Lock()
103	defer r.mtx.Unlock()
104
105	if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
106		// Nothing to do.
107		return nil
108	}
109
110	// Output buffer.
111	buf := &bytes.Buffer{}
112
113	// Moving to the beginning of the section, that we rendered.
114	if r.altScreenActive {
115		buf.WriteString(ansi.CursorHomePosition)
116	} else if r.linesRendered > 1 {
117		buf.WriteString(ansi.CursorUp(r.linesRendered - 1))
118	}
119
120	newLines := strings.Split(r.buf.String(), "\n")
121
122	// If we know the output's height, we can use it to determine how many
123	// lines we can render. We drop lines from the top of the render buffer if
124	// necessary, as we can't navigate the cursor into the terminal's scrollback
125	// buffer.
126	if r.height > 0 && len(newLines) > r.height {
127		newLines = newLines[len(newLines)-r.height:]
128	}
129
130	flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
131
132	if flushQueuedMessages {
133		// Dump the lines we've queued up for printing.
134		for _, line := range r.queuedMessageLines {
135			if ansi.StringWidth(line) < r.width {
136				// We only erase the rest of the line when the line is shorter than
137				// the width of the terminal. When the cursor reaches the end of
138				// the line, any escape sequences that follow will only affect the
139				// last cell of the line.
140
141				// Removing previously rendered content at the end of line.
142				line = line + ansi.EraseLineRight
143			}
144
145			_, _ = buf.WriteString(line)
146			_, _ = buf.WriteString("\r\n")
147		}
148		// Clear the queued message lines.
149		r.queuedMessageLines = []string{}
150	}
151
152	// Paint new lines.
153	for i := range newLines {
154		canSkip := !flushQueuedMessages && // Queuing messages triggers repaint -> we don't have access to previous frame content.
155			len(r.lastRenderedLines) > i && r.lastRenderedLines[i] == newLines[i] // Previously rendered line is the same.
156
157		if _, ignore := r.ignoreLines[i]; ignore || canSkip {
158			// Unless this is the last line, move the cursor down.
159			if i < len(newLines)-1 {
160				buf.WriteByte('\n')
161			}
162			continue
163		}
164
165		if i == 0 && r.lastRender == "" {
166			// On first render, reset the cursor to the start of the line
167			// before writing anything.
168			buf.WriteByte('\r')
169		}
170
171		line := newLines[i]
172
173		// Truncate lines wider than the width of the window to avoid
174		// wrapping, which will mess up rendering. If we don't have the
175		// width of the window this will be ignored.
176		//
177		// Note that on Windows we only get the width of the window on
178		// program initialization, so after a resize this won't perform
179		// correctly (signal SIGWINCH is not supported on Windows).
180		if r.width > 0 {
181			line = ansi.Truncate(line, r.width, "")
182		}
183
184		if ansi.StringWidth(line) < r.width {
185			// We only erase the rest of the line when the line is shorter than
186			// the width of the terminal. When the cursor reaches the end of
187			// the line, any escape sequences that follow will only affect the
188			// last cell of the line.
189
190			// Removing previously rendered content at the end of line.
191			line = line + ansi.EraseLineRight
192		}
193
194		_, _ = buf.WriteString(line)
195
196		if i < len(newLines)-1 {
197			_, _ = buf.WriteString("\r\n")
198		}
199	}
200
201	// Clearing left over content from last render.
202	if r.lastLinesRendered() > len(newLines) {
203		buf.WriteString(ansi.EraseScreenBelow)
204	}
205
206	if r.altScreenActive {
207		r.altLinesRendered = len(newLines)
208	} else {
209		r.linesRendered = len(newLines)
210	}
211
212	// Make sure the cursor is at the start of the last line to keep rendering
213	// behavior consistent.
214	if r.altScreenActive {
215		// This case fixes a bug in macOS terminal. In other terminals the
216		// other case seems to do the job regardless of whether or not we're
217		// using the full terminal window.
218		buf.WriteString(ansi.CursorPosition(0, len(newLines)))
219	} else {
220		buf.WriteString(ansi.CursorBackward(r.width))
221	}
222
223	_, _ = r.out.Write(buf.Bytes())
224	r.lastRender = r.buf.String()
225
226	// Save previously rendered lines for comparison in the next render. If we
227	// don't do this, we can't skip rendering lines that haven't changed.
228	// See https://github.com/charmbracelet/bubbletea/pull/1233
229	r.lastRenderedLines = newLines
230	r.buf.Reset()
231
232	return nil
233}
234
235// lastLinesRendered returns the number of lines rendered lastly.
236func (r *standardRenderer) lastLinesRendered() int {
237	if r.altScreenActive {
238		return r.altLinesRendered
239	}
240	return r.linesRendered
241}
242
243// write writes to the internal buffer. The buffer will be outputted via the
244// ticker which calls flush().
245func (r *standardRenderer) render(v View) {
246	r.mtx.Lock()
247	defer r.mtx.Unlock()
248	r.buf.Reset()
249
250	area := uv.Rect(0, 0, r.width, r.height)
251	if b, ok := v.Layer.(interface{ Bounds() uv.Rectangle }); ok {
252		if !r.altScreenActive {
253			area.Max.Y = b.Bounds().Max.Y
254		}
255	}
256
257	buf := uv.NewScreenBuffer(area.Dx(), area.Dy())
258	v.Layer.Draw(buf, area)
259	s := buf.Render()
260
261	// If an empty string was passed we should clear existing output and
262	// rendering nothing. Rather than introduce additional state to manage
263	// this, we render a single space as a simple (albeit less correct)
264	// solution.
265	if s == "" {
266		s = " "
267	}
268
269	_, _ = r.buf.WriteString(s)
270}
271
272func (r *standardRenderer) repaint() {
273	r.lastRender = ""
274	r.lastRenderedLines = nil
275}
276
277func (r *standardRenderer) clearScreen() {
278	r.mtx.Lock()
279	defer r.mtx.Unlock()
280
281	r.execute(ansi.EraseEntireScreen)
282	r.execute(ansi.CursorHomePosition)
283
284	r.repaint()
285}
286
287func (r *standardRenderer) enterAltScreen() {
288	r.mtx.Lock()
289	defer r.mtx.Unlock()
290
291	if r.altScreenActive {
292		return
293	}
294
295	r.altScreenActive = true
296	r.execute(ansi.SetAltScreenSaveCursorMode)
297
298	// Ensure that the terminal is cleared, even when it doesn't support
299	// alt screen (or alt screen support is disabled, like GNU screen by
300	// default).
301	//
302	// Note: we can't use r.clearScreen() here because the mutex is already
303	// locked.
304	r.execute(ansi.EraseEntireScreen)
305	r.execute(ansi.CursorHomePosition)
306
307	// cmd.exe and other terminals keep separate cursor states for the AltScreen
308	// and the main buffer. We have to explicitly reset the cursor visibility
309	// whenever we enter AltScreen.
310	if r.cursorHidden {
311		r.execute(ansi.HideCursor)
312	} else {
313		r.execute(ansi.ShowCursor)
314	}
315
316	// Entering the alt screen resets the lines rendered count.
317	r.altLinesRendered = 0
318
319	r.repaint()
320}
321
322func (r *standardRenderer) exitAltScreen() {
323	r.mtx.Lock()
324	defer r.mtx.Unlock()
325
326	if !r.altScreenActive {
327		return
328	}
329
330	r.altScreenActive = false
331	r.execute(ansi.ResetAltScreenSaveCursorMode)
332
333	// cmd.exe and other terminals keep separate cursor states for the AltScreen
334	// and the main buffer. We have to explicitly reset the cursor visibility
335	// whenever we exit AltScreen.
336	if r.cursorHidden {
337		r.execute(ansi.HideCursor)
338	} else {
339		r.execute(ansi.ShowCursor)
340	}
341
342	r.repaint()
343}
344
345func (r *standardRenderer) showCursor() {
346	r.mtx.Lock()
347	defer r.mtx.Unlock()
348
349	r.cursorHidden = false
350	r.execute(ansi.ShowCursor)
351}
352
353func (r *standardRenderer) hideCursor() {
354	r.mtx.Lock()
355	defer r.mtx.Unlock()
356
357	r.cursorHidden = true
358	r.execute(ansi.HideCursor)
359}
360
361func (r *standardRenderer) resize(width, height int) {
362	r.mtx.Lock()
363	defer r.mtx.Unlock()
364
365	r.width = width
366	r.height = height
367	r.repaint()
368}
369
370func (r *standardRenderer) insertAbove(s string) {
371	if !r.altScreenActive {
372		lines := strings.Split(s, "\n")
373		r.mtx.Lock()
374		r.queuedMessageLines = append(r.queuedMessageLines, lines...)
375		r.repaint()
376		r.mtx.Unlock()
377	}
378}
379
380func (r *standardRenderer) setCursorColor(c color.Color)     {}
381func (r *standardRenderer) setForegroundColor(c color.Color) {}
382func (r *standardRenderer) setBackgroundColor(c color.Color) {}
383func (r *standardRenderer) setWindowTitle(s string)          {}
384func (r *standardRenderer) hit(MouseMsg) []Msg               { return nil }
385func (r *standardRenderer) resetLinesRendered() {
386	r.linesRendered = 0
387}