cursed_renderer.go

  1package tea
  2
  3import (
  4	"fmt"
  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	"github.com/lucasb-eyer/go-colorful"
 14)
 15
 16type cursedRenderer struct {
 17	w                   io.Writer
 18	scr                 *uv.TerminalRenderer
 19	buf                 uv.ScreenBuffer
 20	lastFrame           *string
 21	lastCur             *Cursor
 22	env                 []string
 23	term                string // the terminal type $TERM
 24	width, height       int
 25	lastFrameHeight     int // the height of the last rendered frame, used to determine if we need to resize the screen buffer
 26	mu                  sync.Mutex
 27	profile             colorprofile.Profile
 28	cursor              Cursor
 29	method              ansi.Method
 30	logger              uv.Logger
 31	layer               Layer // the last rendered layer
 32	setCc, setFg, setBg color.Color
 33	windowTitleSet      string // the last set window title
 34	windowTitle         string // the desired title of the terminal window
 35	altScreen           bool
 36	cursorHidden        bool
 37	hardTabs            bool // whether to use hard tabs to optimize cursor movements
 38	backspace           bool // whether to use backspace to optimize cursor movements
 39	mapnl               bool
 40}
 41
 42var _ renderer = &cursedRenderer{}
 43
 44func newCursedRenderer(w io.Writer, env []string, width, height int, hardTabs, backspace, mapnl bool, logger uv.Logger) (s *cursedRenderer) {
 45	s = new(cursedRenderer)
 46	s.w = w
 47	s.env = env
 48	s.term = uv.Environ(env).Getenv("TERM")
 49	s.logger = logger
 50	s.hardTabs = hardTabs
 51	s.backspace = backspace
 52	s.mapnl = mapnl
 53	s.width, s.height = width, height // This needs to happen before [cursedRenderer.reset].
 54	s.buf = uv.NewScreenBuffer(s.width, s.height)
 55	reset(s)
 56	return
 57}
 58
 59// close implements renderer.
 60func (s *cursedRenderer) close() (err error) {
 61	s.mu.Lock()
 62	defer s.mu.Unlock()
 63
 64	// Go to the bottom of the screen.
 65	s.scr.MoveTo(0, s.buf.Height()-1)
 66
 67	// Exit the altScreen and show cursor before closing. It's important that
 68	// we don't change the [cursedRenderer] altScreen and cursorHidden states
 69	// so that we can restore them when we start the renderer again. This is
 70	// used when the user suspends the program and then resumes it.
 71	if s.altScreen {
 72		s.scr.ExitAltScreen()
 73	}
 74	if s.cursorHidden {
 75		s.scr.ShowCursor()
 76		s.cursorHidden = false
 77	}
 78
 79	if err := s.scr.Flush(); err != nil {
 80		return fmt.Errorf("bubbletea: error closing screen writer: %w", err)
 81	}
 82
 83	x, y := s.scr.Position()
 84
 85	// We want to clear the renderer state but not the cursor position. This is
 86	// because we might be putting the tea process in the background, run some
 87	// other process, and then return to the tea process. We want to keep the
 88	// cursor position so that we can continue where we left off.
 89	reset(s)
 90	s.scr.SetPosition(x, y)
 91
 92	// Reset cursor style state so that we can restore it again when we start
 93	// the renderer again.
 94	s.cursor = Cursor{}
 95
 96	return nil
 97}
 98
 99// writeString implements renderer.
100func (s *cursedRenderer) writeString(str string) (int, error) {
101	s.mu.Lock()
102	defer s.mu.Unlock()
103
104	return s.scr.WriteString(str)
105}
106
107// resetLinesRendered implements renderer.
108func (s *cursedRenderer) resetLinesRendered() {
109	s.mu.Lock()
110	defer s.mu.Unlock()
111
112	if !s.altScreen {
113		var frameHeight int
114		if s.lastFrame != nil {
115			frameHeight = strings.Count(*s.lastFrame, "\n") + 1
116		}
117
118		io.WriteString(s.w, strings.Repeat("\n", max(0, frameHeight-1))) //nolint:errcheck,gosec
119	}
120}
121
122// flush implements renderer.
123func (s *cursedRenderer) flush(p *Program) error {
124	s.mu.Lock()
125	defer s.mu.Unlock()
126
127	// Set window title.
128	if s.windowTitle != s.windowTitleSet {
129		_, _ = s.scr.WriteString(ansi.SetWindowTitle(s.windowTitle))
130		s.windowTitleSet = s.windowTitle
131	}
132	// Set terminal colors.
133	for _, c := range []struct {
134		rendererColor *color.Color
135		programColor  *color.Color
136		reset         string
137		setter        func(string) string
138	}{
139		{rendererColor: &s.setCc, programColor: &p.setCc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor},
140		{rendererColor: &s.setFg, programColor: &p.setFg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor},
141		{rendererColor: &s.setBg, programColor: &p.setBg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor},
142	} {
143		if *c.rendererColor != *c.programColor {
144			if *c.rendererColor == nil {
145				// Reset the color if it was set to nil.
146				_, _ = s.scr.WriteString(c.reset)
147			} else {
148				// Set the color.
149				col, ok := colorful.MakeColor(*c.rendererColor)
150				if ok {
151					_, _ = s.scr.WriteString(c.setter(col.Hex()))
152				}
153			}
154			*c.programColor = *c.rendererColor
155		}
156	}
157
158	if s.lastCur != nil {
159		if s.lastCur.Shape != s.cursor.Shape || s.lastCur.Blink != s.cursor.Blink {
160			cursorStyle := encodeCursorStyle(s.lastCur.Shape, s.lastCur.Blink)
161			_, _ = s.scr.WriteString(ansi.SetCursorStyle(cursorStyle))
162			s.cursor.Shape = s.lastCur.Shape
163			s.cursor.Blink = s.lastCur.Blink
164		}
165		if s.lastCur.Color != s.cursor.Color {
166			seq := ansi.ResetCursorColor
167			if s.lastCur.Color != nil {
168				c, ok := colorful.MakeColor(s.lastCur.Color)
169				if ok {
170					seq = ansi.SetCursorColor(c.Hex())
171				}
172			}
173			_, _ = s.scr.WriteString(seq)
174			s.cursor.Color = s.lastCur.Color
175		}
176	}
177
178	// Render and queue changes to the screen buffer.
179	s.scr.Render(s.buf.Buffer)
180	if s.lastCur != nil {
181		// MoveTo must come after [uv.TerminalRenderer.Render] because the
182		// cursor position might get updated during rendering.
183		s.scr.MoveTo(s.lastCur.X, s.lastCur.Y)
184		s.cursor.Position = s.lastCur.Position
185	}
186
187	if err := s.scr.Flush(); err != nil {
188		return fmt.Errorf("bubbletea: error flushing screen writer: %w", err)
189	}
190	return nil
191}
192
193// render implements renderer.
194func (s *cursedRenderer) render(v View) {
195	s.mu.Lock()
196	defer s.mu.Unlock()
197
198	frameArea := uv.Rect(0, 0, s.width, s.height)
199	if v.Layer == nil {
200		// If the component is nil, we should clear the screen buffer.
201		frameArea.Max.Y = 0
202	}
203
204	if !s.altScreen {
205		// Inline mode resizes the screen based on the frame height and
206		// terminal width. This is because the frame height can change based on
207		// the content of the frame. For example, if the frame contains a list
208		// of items, the height of the frame will be the number of items in the
209		// list. This is different from the alt screen buffer, which has a
210		// fixed height and width.
211		switch l := v.Layer.(type) {
212		case *uv.StyledString:
213			frameArea.Max.Y = l.Height()
214		case interface{ Bounds() uv.Rectangle }:
215			frameArea.Max.Y = l.Bounds().Dy()
216		}
217
218		// Resize the screen buffer to match the frame area. This is necessary
219		// to ensure that the screen buffer is the same size as the frame area
220		// and to avoid rendering issues when the frame area is smaller than
221		// the screen buffer.
222		s.buf.Resize(frameArea.Dx(), frameArea.Dy())
223	}
224	// Clear our screen buffer before copying the new frame into it to ensure
225	// we erase any old content.
226	s.buf.Clear()
227	if v.Layer != nil {
228		v.Layer.Draw(s.buf, frameArea)
229	}
230
231	frame := s.buf.Render()
232
233	// If an empty string was passed we should clear existing output and
234	// rendering nothing. Rather than introduce additional state to manage
235	// this, we render a single space as a simple (albeit less correct)
236	// solution.
237	if frame == "" {
238		frame = " "
239	}
240
241	cur := v.Cursor
242
243	s.windowTitle = v.WindowTitle
244
245	// Ensure we have any desired terminal colors set.
246	s.setBg = v.BackgroundColor
247	s.setFg = v.ForegroundColor
248	if cur != nil {
249		s.setCc = cur.Color
250	}
251	if s.lastFrame != nil && frame == *s.lastFrame &&
252		(s.lastCur == nil && cur == nil || s.lastCur != nil && cur != nil && *s.lastCur == *cur) {
253		return
254	}
255
256	s.layer = v.Layer
257	s.lastCur = cur
258	s.lastFrameHeight = frameArea.Dy()
259
260	// Cache the last rendered frame so we can avoid re-rendering it if
261	// the frame hasn't changed.
262	lastFrame := frame
263	s.lastFrame = &lastFrame
264
265	if cur == nil {
266		enableTextCursor(s, false)
267	} else {
268		enableTextCursor(s, true)
269	}
270}
271
272// hit implements renderer.
273func (s *cursedRenderer) hit(mouse MouseMsg) []Msg {
274	s.mu.Lock()
275	defer s.mu.Unlock()
276
277	if s.layer != nil {
278		if h, ok := s.layer.(Hittable); ok {
279			m := mouse.Mouse()
280			if id := h.Hit(m.X, m.Y); id != "" {
281				return []Msg{LayerHitMsg{
282					ID:    id,
283					Mouse: mouse,
284				}}
285			}
286		}
287	}
288
289	return []Msg{}
290}
291
292// setCursorColor implements renderer.
293func (s *cursedRenderer) setCursorColor(c color.Color) {
294	s.mu.Lock()
295	s.setCc = c
296	s.mu.Unlock()
297}
298
299// setForegroundColor implements renderer.
300func (s *cursedRenderer) setForegroundColor(c color.Color) {
301	s.mu.Lock()
302	s.setFg = c
303	s.mu.Unlock()
304}
305
306// setBackgroundColor implements renderer.
307func (s *cursedRenderer) setBackgroundColor(c color.Color) {
308	s.mu.Lock()
309	s.setBg = c
310	s.mu.Unlock()
311}
312
313// setWindowTitle implements renderer.
314func (s *cursedRenderer) setWindowTitle(title string) {
315	s.mu.Lock()
316	s.windowTitle = title
317	s.mu.Unlock()
318}
319
320// reset implements renderer.
321func (s *cursedRenderer) reset() {
322	s.mu.Lock()
323	reset(s)
324	s.mu.Unlock()
325}
326
327func reset(s *cursedRenderer) {
328	scr := uv.NewTerminalRenderer(s.w, s.env)
329	scr.SetColorProfile(s.profile)
330	scr.SetRelativeCursor(!s.altScreen)
331	scr.SetTabStops(s.width)
332	scr.SetBackspace(s.backspace)
333	scr.SetMapNewline(s.mapnl)
334	scr.SetLogger(s.logger)
335	if s.altScreen {
336		scr.EnterAltScreen()
337	} else {
338		scr.ExitAltScreen()
339	}
340	if !s.cursorHidden {
341		scr.ShowCursor()
342	} else {
343		scr.HideCursor()
344	}
345	s.scr = scr
346}
347
348// setColorProfile implements renderer.
349func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) {
350	s.mu.Lock()
351	s.profile = p
352	s.scr.SetColorProfile(p)
353	s.mu.Unlock()
354}
355
356// resize implements renderer.
357func (s *cursedRenderer) resize(w, h int) {
358	s.mu.Lock()
359	if s.altScreen || w != s.width {
360		// We need to mark the screen for clear to force a redraw. However, we
361		// only do so if we're using alt screen or the width has changed.
362		// That's because redrawing is expensive and we can avoid it if the
363		// width hasn't changed in inline mode. On the other hand, when using
364		// alt screen mode, we always want to redraw because some terminals
365		// would scroll the screen and our content would be lost.
366		s.scr.Erase()
367	}
368	if s.altScreen {
369		s.buf.Resize(w, h)
370	}
371
372	// We need to reset the touched lines buffer to match the new height.
373	s.buf.Touched = nil
374
375	s.scr.Resize(s.width, s.height)
376	s.width, s.height = w, h
377	repaint(s)
378	s.mu.Unlock()
379}
380
381// clearScreen implements renderer.
382func (s *cursedRenderer) clearScreen() {
383	s.mu.Lock()
384	// Move the cursor to the top left corner of the screen and trigger a full
385	// screen redraw.
386	_, _ = s.scr.WriteString(ansi.CursorHomePosition)
387	s.scr.Redraw(s.buf.Buffer) // force redraw
388	repaint(s)
389	s.mu.Unlock()
390}
391
392// enableAltScreen sets the alt screen mode.
393func enableAltScreen(s *cursedRenderer, enable bool) {
394	s.altScreen = enable
395	if enable {
396		s.scr.EnterAltScreen()
397	} else {
398		s.scr.ExitAltScreen()
399	}
400	s.scr.SetRelativeCursor(!s.altScreen)
401	repaint(s)
402}
403
404// enterAltScreen implements renderer.
405func (s *cursedRenderer) enterAltScreen() {
406	s.mu.Lock()
407	enableAltScreen(s, true)
408	s.mu.Unlock()
409}
410
411// exitAltScreen implements renderer.
412func (s *cursedRenderer) exitAltScreen() {
413	s.mu.Lock()
414	enableAltScreen(s, false)
415	s.mu.Unlock()
416}
417
418// enableTextCursor sets the text cursor mode.
419func enableTextCursor(s *cursedRenderer, enable bool) {
420	s.cursorHidden = !enable
421	if enable {
422		s.scr.ShowCursor()
423	} else {
424		s.scr.HideCursor()
425	}
426}
427
428// showCursor implements renderer.
429func (s *cursedRenderer) showCursor() {
430	s.mu.Lock()
431	enableTextCursor(s, true)
432	s.mu.Unlock()
433}
434
435// hideCursor implements renderer.
436func (s *cursedRenderer) hideCursor() {
437	s.mu.Lock()
438	enableTextCursor(s, false)
439	s.mu.Unlock()
440}
441
442// insertAbove implements renderer.
443func (s *cursedRenderer) insertAbove(lines string) {
444	s.mu.Lock()
445	strLines := strings.Split(lines, "\n")
446	for i, line := range strLines {
447		if ansi.StringWidth(line) > s.width {
448			// If the line is wider than the screen, truncate it.
449			line = ansi.Truncate(line, s.width, "")
450		}
451		strLines[i] = line
452	}
453	s.scr.PrependString(strings.Join(strLines, "\n"))
454	s.mu.Unlock()
455}
456
457func (s *cursedRenderer) repaint() {
458	s.mu.Lock()
459	repaint(s)
460	s.mu.Unlock()
461}
462
463func repaint(s *cursedRenderer) {
464	s.lastFrame = nil
465}