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// flush implements renderer.
108func (s *cursedRenderer) flush(p *Program) error {
109	s.mu.Lock()
110	defer s.mu.Unlock()
111
112	// Set window title.
113	if s.windowTitle != s.windowTitleSet {
114		_, _ = s.scr.WriteString(ansi.SetWindowTitle(s.windowTitle))
115		s.windowTitleSet = s.windowTitle
116	}
117	// Set terminal colors.
118	for _, c := range []struct {
119		rendererColor *color.Color
120		programColor  *color.Color
121		reset         string
122		setter        func(string) string
123	}{
124		{rendererColor: &s.setCc, programColor: &p.setCc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor},
125		{rendererColor: &s.setFg, programColor: &p.setFg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor},
126		{rendererColor: &s.setBg, programColor: &p.setBg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor},
127	} {
128		if *c.rendererColor != *c.programColor {
129			if *c.rendererColor == nil {
130				// Reset the color if it was set to nil.
131				_, _ = s.scr.WriteString(c.reset)
132			} else {
133				// Set the color.
134				col, ok := colorful.MakeColor(*c.rendererColor)
135				if ok {
136					_, _ = s.scr.WriteString(c.setter(col.Hex()))
137				}
138			}
139			*c.programColor = *c.rendererColor
140		}
141	}
142
143	if s.lastCur != nil {
144		if s.lastCur.Shape != s.cursor.Shape || s.lastCur.Blink != s.cursor.Blink {
145			cursorStyle := encodeCursorStyle(s.lastCur.Shape, s.lastCur.Blink)
146			_, _ = s.scr.WriteString(ansi.SetCursorStyle(cursorStyle))
147			s.cursor.Shape = s.lastCur.Shape
148			s.cursor.Blink = s.lastCur.Blink
149		}
150		if s.lastCur.Color != s.cursor.Color {
151			seq := ansi.ResetCursorColor
152			if s.lastCur.Color != nil {
153				c, ok := colorful.MakeColor(s.lastCur.Color)
154				if ok {
155					seq = ansi.SetCursorColor(c.Hex())
156				}
157			}
158			_, _ = s.scr.WriteString(seq)
159			s.cursor.Color = s.lastCur.Color
160		}
161	}
162
163	// Render and queue changes to the screen buffer.
164	s.scr.Render(s.buf.Buffer)
165	if s.lastCur != nil {
166		// MoveTo must come after [uv.TerminalRenderer.Render] because the
167		// cursor position might get updated during rendering.
168		s.scr.MoveTo(s.lastCur.X, s.lastCur.Y)
169		s.cursor.Position = s.lastCur.Position
170	}
171
172	if err := s.scr.Flush(); err != nil {
173		return fmt.Errorf("bubbletea: error flushing screen writer: %w", err)
174	}
175	return nil
176}
177
178// render implements renderer.
179func (s *cursedRenderer) render(v View) {
180	s.mu.Lock()
181	defer s.mu.Unlock()
182
183	frameArea := uv.Rect(0, 0, s.width, s.height)
184	if v.Layer == nil {
185		// If the component is nil, we should clear the screen buffer.
186		frameArea.Max.Y = 0
187	}
188
189	if !s.altScreen {
190		// Inline mode resizes the screen based on the frame height and
191		// terminal width. This is because the frame height can change based on
192		// the content of the frame. For example, if the frame contains a list
193		// of items, the height of the frame will be the number of items in the
194		// list. This is different from the alt screen buffer, which has a
195		// fixed height and width.
196		switch l := v.Layer.(type) {
197		case *uv.StyledString:
198			frameArea.Max.Y = l.Height()
199		case interface{ Bounds() uv.Rectangle }:
200			frameArea.Max.Y = l.Bounds().Dy()
201		}
202
203		// Resize the screen buffer to match the frame area. This is necessary
204		// to ensure that the screen buffer is the same size as the frame area
205		// and to avoid rendering issues when the frame area is smaller than
206		// the screen buffer.
207		s.buf.Resize(frameArea.Dx(), frameArea.Dy())
208	}
209	// Clear our screen buffer before copying the new frame into it to ensure
210	// we erase any old content.
211	s.buf.Clear()
212	if v.Layer != nil {
213		v.Layer.Draw(s.buf, frameArea)
214	}
215
216	frame := s.buf.Render()
217
218	// If an empty string was passed we should clear existing output and
219	// rendering nothing. Rather than introduce additional state to manage
220	// this, we render a single space as a simple (albeit less correct)
221	// solution.
222	if frame == "" {
223		frame = " "
224	}
225
226	cur := v.Cursor
227
228	s.windowTitle = v.WindowTitle
229
230	// Ensure we have any desired terminal colors set.
231	s.setBg = v.BackgroundColor
232	s.setFg = v.ForegroundColor
233	if cur != nil {
234		s.setCc = cur.Color
235	}
236	if s.lastFrame != nil && frame == *s.lastFrame &&
237		(s.lastCur == nil && cur == nil || s.lastCur != nil && cur != nil && *s.lastCur == *cur) {
238		return
239	}
240
241	s.layer = v.Layer
242	s.lastCur = cur
243	s.lastFrameHeight = frameArea.Dy()
244
245	// Cache the last rendered frame so we can avoid re-rendering it if
246	// the frame hasn't changed.
247	lastFrame := frame
248	s.lastFrame = &lastFrame
249
250	if cur == nil {
251		enableTextCursor(s, false)
252	} else {
253		enableTextCursor(s, true)
254	}
255}
256
257// hit implements renderer.
258func (s *cursedRenderer) hit(mouse MouseMsg) []Msg {
259	s.mu.Lock()
260	defer s.mu.Unlock()
261
262	if s.layer != nil {
263		if h, ok := s.layer.(Hittable); ok {
264			m := mouse.Mouse()
265			if id := h.Hit(m.X, m.Y); id != "" {
266				return []Msg{LayerHitMsg{
267					ID:    id,
268					Mouse: mouse,
269				}}
270			}
271		}
272	}
273
274	return []Msg{}
275}
276
277// setCursorColor implements renderer.
278func (s *cursedRenderer) setCursorColor(c color.Color) {
279	s.mu.Lock()
280	s.setCc = c
281	s.mu.Unlock()
282}
283
284// setForegroundColor implements renderer.
285func (s *cursedRenderer) setForegroundColor(c color.Color) {
286	s.mu.Lock()
287	s.setFg = c
288	s.mu.Unlock()
289}
290
291// setBackgroundColor implements renderer.
292func (s *cursedRenderer) setBackgroundColor(c color.Color) {
293	s.mu.Lock()
294	s.setBg = c
295	s.mu.Unlock()
296}
297
298// setWindowTitle implements renderer.
299func (s *cursedRenderer) setWindowTitle(title string) {
300	s.mu.Lock()
301	s.windowTitle = title
302	s.mu.Unlock()
303}
304
305// reset implements renderer.
306func (s *cursedRenderer) reset() {
307	s.mu.Lock()
308	reset(s)
309	s.mu.Unlock()
310}
311
312func reset(s *cursedRenderer) {
313	scr := uv.NewTerminalRenderer(s.w, s.env)
314	scr.SetColorProfile(s.profile)
315	scr.SetRelativeCursor(!s.altScreen)
316	scr.SetTabStops(s.width)
317	scr.SetBackspace(s.backspace)
318	scr.SetMapNewline(s.mapnl)
319	scr.SetLogger(s.logger)
320	if s.altScreen {
321		scr.EnterAltScreen()
322	} else {
323		scr.ExitAltScreen()
324	}
325	if !s.cursorHidden {
326		scr.ShowCursor()
327	} else {
328		scr.HideCursor()
329	}
330	s.scr = scr
331}
332
333// setColorProfile implements renderer.
334func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) {
335	s.mu.Lock()
336	s.profile = p
337	s.scr.SetColorProfile(p)
338	s.mu.Unlock()
339}
340
341// resize implements renderer.
342func (s *cursedRenderer) resize(w, h int) {
343	s.mu.Lock()
344	if s.altScreen || w != s.width {
345		// We need to mark the screen for clear to force a redraw. However, we
346		// only do so if we're using alt screen or the width has changed.
347		// That's because redrawing is expensive and we can avoid it if the
348		// width hasn't changed in inline mode. On the other hand, when using
349		// alt screen mode, we always want to redraw because some terminals
350		// would scroll the screen and our content would be lost.
351		s.scr.Erase()
352	}
353	if s.altScreen {
354		s.buf.Resize(w, h)
355	}
356
357	// We need to reset the touched lines buffer to match the new height.
358	s.buf.Touched = nil
359
360	s.scr.Resize(s.width, s.height)
361	s.width, s.height = w, h
362	repaint(s)
363	s.mu.Unlock()
364}
365
366// clearScreen implements renderer.
367func (s *cursedRenderer) clearScreen() {
368	s.mu.Lock()
369	// Move the cursor to the top left corner of the screen and trigger a full
370	// screen redraw.
371	_, _ = s.scr.WriteString(ansi.CursorHomePosition)
372	s.scr.Redraw(s.buf.Buffer) // force redraw
373	repaint(s)
374	s.mu.Unlock()
375}
376
377// enableAltScreen sets the alt screen mode.
378func enableAltScreen(s *cursedRenderer, enable bool) {
379	s.altScreen = enable
380	if enable {
381		s.scr.EnterAltScreen()
382	} else {
383		s.scr.ExitAltScreen()
384	}
385	s.scr.SetRelativeCursor(!s.altScreen)
386	repaint(s)
387}
388
389// enterAltScreen implements renderer.
390func (s *cursedRenderer) enterAltScreen() {
391	s.mu.Lock()
392	enableAltScreen(s, true)
393	s.mu.Unlock()
394}
395
396// exitAltScreen implements renderer.
397func (s *cursedRenderer) exitAltScreen() {
398	s.mu.Lock()
399	enableAltScreen(s, false)
400	s.mu.Unlock()
401}
402
403// enableTextCursor sets the text cursor mode.
404func enableTextCursor(s *cursedRenderer, enable bool) {
405	s.cursorHidden = !enable
406	if enable {
407		s.scr.ShowCursor()
408	} else {
409		s.scr.HideCursor()
410	}
411}
412
413// showCursor implements renderer.
414func (s *cursedRenderer) showCursor() {
415	s.mu.Lock()
416	enableTextCursor(s, true)
417	s.mu.Unlock()
418}
419
420// hideCursor implements renderer.
421func (s *cursedRenderer) hideCursor() {
422	s.mu.Lock()
423	enableTextCursor(s, false)
424	s.mu.Unlock()
425}
426
427// insertAbove implements renderer.
428func (s *cursedRenderer) insertAbove(lines string) {
429	s.mu.Lock()
430	strLines := strings.Split(lines, "\n")
431	for i, line := range strLines {
432		if ansi.StringWidth(line) > s.width {
433			// If the line is wider than the screen, truncate it.
434			line = ansi.Truncate(line, s.width, "")
435		}
436		strLines[i] = line
437	}
438	s.scr.PrependString(strings.Join(strLines, "\n"))
439	s.mu.Unlock()
440}
441
442func (s *cursedRenderer) repaint() {
443	s.mu.Lock()
444	repaint(s)
445	s.mu.Unlock()
446}
447
448func repaint(s *cursedRenderer) {
449	s.lastFrame = nil
450}