main
1package tea
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "strings"
8 "sync"
9 "time"
10
11 "github.com/charmbracelet/x/ansi"
12 "github.com/muesli/ansi/compressor"
13)
14
15const (
16 // defaultFramerate specifies the maximum interval at which we should
17 // update the view.
18 defaultFPS = 60
19 maxFPS = 120
20)
21
22// standardRenderer is a framerate-based terminal renderer, updating the view
23// at a given framerate to avoid overloading the terminal emulator.
24//
25// In cases where very high performance is needed the renderer can be told
26// to exclude ranges of lines, allowing them to be written to directly.
27type standardRenderer struct {
28 mtx *sync.Mutex
29 out io.Writer
30
31 buf bytes.Buffer
32 queuedMessageLines []string
33 framerate time.Duration
34 ticker *time.Ticker
35 done chan struct{}
36 lastRender string
37 lastRenderedLines []string
38 linesRendered int
39 altLinesRendered int
40 useANSICompressor bool
41 once sync.Once
42
43 // cursor visibility state
44 cursorHidden bool
45
46 // essentially whether or not we're using the full size of the terminal
47 altScreenActive bool
48
49 // whether or not we're currently using bracketed paste
50 bpActive bool
51
52 // reportingFocus whether reporting focus events is enabled
53 reportingFocus bool
54
55 // renderer dimensions; usually the size of the window
56 width int
57 height int
58
59 // lines explicitly set not to render
60 ignoreLines map[int]struct{}
61}
62
63// newRenderer creates a new renderer. Normally you'll want to initialize it
64// with os.Stdout as the first argument.
65func newRenderer(out io.Writer, useANSICompressor bool, fps int) renderer {
66 if fps < 1 {
67 fps = defaultFPS
68 } else if fps > maxFPS {
69 fps = maxFPS
70 }
71 r := &standardRenderer{
72 out: out,
73 mtx: &sync.Mutex{},
74 done: make(chan struct{}),
75 framerate: time.Second / time.Duration(fps),
76 useANSICompressor: useANSICompressor,
77 queuedMessageLines: []string{},
78 }
79 if r.useANSICompressor {
80 r.out = &compressor.Writer{Forward: out}
81 }
82 return r
83}
84
85// start starts the renderer.
86func (r *standardRenderer) start() {
87 if r.ticker == nil {
88 r.ticker = time.NewTicker(r.framerate)
89 } else {
90 // If the ticker already exists, it has been stopped and we need to
91 // reset it.
92 r.ticker.Reset(r.framerate)
93 }
94
95 // Since the renderer can be restarted after a stop, we need to reset
96 // the done channel and its corresponding sync.Once.
97 r.once = sync.Once{}
98
99 go r.listen()
100}
101
102// stop permanently halts the renderer, rendering the final frame.
103func (r *standardRenderer) stop() {
104 // Stop the renderer before acquiring the mutex to avoid a deadlock.
105 r.once.Do(func() {
106 r.done <- struct{}{}
107 })
108
109 // flush locks the mutex
110 r.flush()
111
112 r.mtx.Lock()
113 defer r.mtx.Unlock()
114
115 r.execute(ansi.EraseEntireLine)
116 // Move the cursor back to the beginning of the line
117 r.execute("\r")
118
119 if r.useANSICompressor {
120 if w, ok := r.out.(io.WriteCloser); ok {
121 _ = w.Close()
122 }
123 }
124}
125
126// execute writes a sequence to the terminal.
127func (r *standardRenderer) execute(seq string) {
128 _, _ = io.WriteString(r.out, seq)
129}
130
131// kill halts the renderer. The final frame will not be rendered.
132func (r *standardRenderer) kill() {
133 // Stop the renderer before acquiring the mutex to avoid a deadlock.
134 r.once.Do(func() {
135 r.done <- struct{}{}
136 })
137
138 r.mtx.Lock()
139 defer r.mtx.Unlock()
140
141 r.execute(ansi.EraseEntireLine)
142 // Move the cursor back to the beginning of the line
143 r.execute("\r")
144}
145
146// listen waits for ticks on the ticker, or a signal to stop the renderer.
147func (r *standardRenderer) listen() {
148 for {
149 select {
150 case <-r.done:
151 r.ticker.Stop()
152 return
153
154 case <-r.ticker.C:
155 r.flush()
156 }
157 }
158}
159
160// flush renders the buffer.
161func (r *standardRenderer) flush() {
162 r.mtx.Lock()
163 defer r.mtx.Unlock()
164
165 if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
166 // Nothing to do.
167 return
168 }
169
170 // Output buffer.
171 buf := &bytes.Buffer{}
172
173 // Moving to the beginning of the section, that we rendered.
174 if r.altScreenActive {
175 buf.WriteString(ansi.CursorHomePosition)
176 } else if r.linesRendered > 1 {
177 buf.WriteString(ansi.CursorUp(r.linesRendered - 1))
178 }
179
180 newLines := strings.Split(r.buf.String(), "\n")
181
182 // If we know the output's height, we can use it to determine how many
183 // lines we can render. We drop lines from the top of the render buffer if
184 // necessary, as we can't navigate the cursor into the terminal's scrollback
185 // buffer.
186 if r.height > 0 && len(newLines) > r.height {
187 newLines = newLines[len(newLines)-r.height:]
188 }
189
190 flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
191
192 if flushQueuedMessages {
193 // Dump the lines we've queued up for printing.
194 for _, line := range r.queuedMessageLines {
195 if ansi.StringWidth(line) < r.width {
196 // We only erase the rest of the line when the line is shorter than
197 // the width of the terminal. When the cursor reaches the end of
198 // the line, any escape sequences that follow will only affect the
199 // last cell of the line.
200
201 // Removing previously rendered content at the end of line.
202 line = line + ansi.EraseLineRight
203 }
204
205 _, _ = buf.WriteString(line)
206 _, _ = buf.WriteString("\r\n")
207 }
208 // Clear the queued message lines.
209 r.queuedMessageLines = []string{}
210 }
211
212 // Paint new lines.
213 for i := 0; i < len(newLines); i++ {
214 canSkip := !flushQueuedMessages && // Queuing messages triggers repaint -> we don't have access to previous frame content.
215 len(r.lastRenderedLines) > i && r.lastRenderedLines[i] == newLines[i] // Previously rendered line is the same.
216
217 if _, ignore := r.ignoreLines[i]; ignore || canSkip {
218 // Unless this is the last line, move the cursor down.
219 if i < len(newLines)-1 {
220 buf.WriteByte('\n')
221 }
222 continue
223 }
224
225 if i == 0 && r.lastRender == "" {
226 // On first render, reset the cursor to the start of the line
227 // before writing anything.
228 buf.WriteByte('\r')
229 }
230
231 line := newLines[i]
232
233 // Truncate lines wider than the width of the window to avoid
234 // wrapping, which will mess up rendering. If we don't have the
235 // width of the window this will be ignored.
236 //
237 // Note that on Windows we only get the width of the window on
238 // program initialization, so after a resize this won't perform
239 // correctly (signal SIGWINCH is not supported on Windows).
240 if r.width > 0 {
241 line = ansi.Truncate(line, r.width, "")
242 }
243
244 if ansi.StringWidth(line) < r.width {
245 // We only erase the rest of the line when the line is shorter than
246 // the width of the terminal. When the cursor reaches the end of
247 // the line, any escape sequences that follow will only affect the
248 // last cell of the line.
249
250 // Removing previously rendered content at the end of line.
251 line = line + ansi.EraseLineRight
252 }
253
254 _, _ = buf.WriteString(line)
255
256 if i < len(newLines)-1 {
257 _, _ = buf.WriteString("\r\n")
258 }
259 }
260
261 // Clearing left over content from last render.
262 if r.lastLinesRendered() > len(newLines) {
263 buf.WriteString(ansi.EraseScreenBelow)
264 }
265
266 if r.altScreenActive {
267 r.altLinesRendered = len(newLines)
268 } else {
269 r.linesRendered = len(newLines)
270 }
271
272 // Make sure the cursor is at the start of the last line to keep rendering
273 // behavior consistent.
274 if r.altScreenActive {
275 // This case fixes a bug in macOS terminal. In other terminals the
276 // other case seems to do the job regardless of whether or not we're
277 // using the full terminal window.
278 buf.WriteString(ansi.CursorPosition(0, len(newLines)))
279 } else {
280 buf.WriteString(ansi.CursorBackward(r.width))
281 }
282
283 _, _ = r.out.Write(buf.Bytes())
284 r.lastRender = r.buf.String()
285
286 // Save previously rendered lines for comparison in the next render. If we
287 // don't do this, we can't skip rendering lines that haven't changed.
288 // See https://github.com/charmbracelet/bubbletea/pull/1233
289 r.lastRenderedLines = newLines
290 r.buf.Reset()
291}
292
293// lastLinesRendered returns the number of lines rendered lastly.
294func (r *standardRenderer) lastLinesRendered() int {
295 if r.altScreenActive {
296 return r.altLinesRendered
297 }
298 return r.linesRendered
299}
300
301// write writes to the internal buffer. The buffer will be outputted via the
302// ticker which calls flush().
303func (r *standardRenderer) write(s string) {
304 r.mtx.Lock()
305 defer r.mtx.Unlock()
306 r.buf.Reset()
307
308 // If an empty string was passed we should clear existing output and
309 // rendering nothing. Rather than introduce additional state to manage
310 // this, we render a single space as a simple (albeit less correct)
311 // solution.
312 if s == "" {
313 s = " "
314 }
315
316 _, _ = r.buf.WriteString(s)
317}
318
319func (r *standardRenderer) repaint() {
320 r.lastRender = ""
321 r.lastRenderedLines = nil
322}
323
324func (r *standardRenderer) clearScreen() {
325 r.mtx.Lock()
326 defer r.mtx.Unlock()
327
328 r.execute(ansi.EraseEntireScreen)
329 r.execute(ansi.CursorHomePosition)
330
331 r.repaint()
332}
333
334func (r *standardRenderer) altScreen() bool {
335 r.mtx.Lock()
336 defer r.mtx.Unlock()
337
338 return r.altScreenActive
339}
340
341func (r *standardRenderer) enterAltScreen() {
342 r.mtx.Lock()
343 defer r.mtx.Unlock()
344
345 if r.altScreenActive {
346 return
347 }
348
349 r.altScreenActive = true
350 r.execute(ansi.SetAltScreenSaveCursorMode)
351
352 // Ensure that the terminal is cleared, even when it doesn't support
353 // alt screen (or alt screen support is disabled, like GNU screen by
354 // default).
355 //
356 // Note: we can't use r.clearScreen() here because the mutex is already
357 // locked.
358 r.execute(ansi.EraseEntireScreen)
359 r.execute(ansi.CursorHomePosition)
360
361 // cmd.exe and other terminals keep separate cursor states for the AltScreen
362 // and the main buffer. We have to explicitly reset the cursor visibility
363 // whenever we enter AltScreen.
364 if r.cursorHidden {
365 r.execute(ansi.HideCursor)
366 } else {
367 r.execute(ansi.ShowCursor)
368 }
369
370 // Entering the alt screen resets the lines rendered count.
371 r.altLinesRendered = 0
372
373 r.repaint()
374}
375
376func (r *standardRenderer) exitAltScreen() {
377 r.mtx.Lock()
378 defer r.mtx.Unlock()
379
380 if !r.altScreenActive {
381 return
382 }
383
384 r.altScreenActive = false
385 r.execute(ansi.ResetAltScreenSaveCursorMode)
386
387 // cmd.exe and other terminals keep separate cursor states for the AltScreen
388 // and the main buffer. We have to explicitly reset the cursor visibility
389 // whenever we exit AltScreen.
390 if r.cursorHidden {
391 r.execute(ansi.HideCursor)
392 } else {
393 r.execute(ansi.ShowCursor)
394 }
395
396 r.repaint()
397}
398
399func (r *standardRenderer) showCursor() {
400 r.mtx.Lock()
401 defer r.mtx.Unlock()
402
403 r.cursorHidden = false
404 r.execute(ansi.ShowCursor)
405}
406
407func (r *standardRenderer) hideCursor() {
408 r.mtx.Lock()
409 defer r.mtx.Unlock()
410
411 r.cursorHidden = true
412 r.execute(ansi.HideCursor)
413}
414
415func (r *standardRenderer) enableMouseCellMotion() {
416 r.mtx.Lock()
417 defer r.mtx.Unlock()
418
419 r.execute(ansi.SetButtonEventMouseMode)
420}
421
422func (r *standardRenderer) disableMouseCellMotion() {
423 r.mtx.Lock()
424 defer r.mtx.Unlock()
425
426 r.execute(ansi.ResetButtonEventMouseMode)
427}
428
429func (r *standardRenderer) enableMouseAllMotion() {
430 r.mtx.Lock()
431 defer r.mtx.Unlock()
432
433 r.execute(ansi.SetAnyEventMouseMode)
434}
435
436func (r *standardRenderer) disableMouseAllMotion() {
437 r.mtx.Lock()
438 defer r.mtx.Unlock()
439
440 r.execute(ansi.ResetAnyEventMouseMode)
441}
442
443func (r *standardRenderer) enableMouseSGRMode() {
444 r.mtx.Lock()
445 defer r.mtx.Unlock()
446
447 r.execute(ansi.SetSgrExtMouseMode)
448}
449
450func (r *standardRenderer) disableMouseSGRMode() {
451 r.mtx.Lock()
452 defer r.mtx.Unlock()
453
454 r.execute(ansi.ResetSgrExtMouseMode)
455}
456
457func (r *standardRenderer) enableBracketedPaste() {
458 r.mtx.Lock()
459 defer r.mtx.Unlock()
460
461 r.execute(ansi.SetBracketedPasteMode)
462 r.bpActive = true
463}
464
465func (r *standardRenderer) disableBracketedPaste() {
466 r.mtx.Lock()
467 defer r.mtx.Unlock()
468
469 r.execute(ansi.ResetBracketedPasteMode)
470 r.bpActive = false
471}
472
473func (r *standardRenderer) bracketedPasteActive() bool {
474 r.mtx.Lock()
475 defer r.mtx.Unlock()
476
477 return r.bpActive
478}
479
480func (r *standardRenderer) enableReportFocus() {
481 r.mtx.Lock()
482 defer r.mtx.Unlock()
483
484 r.execute(ansi.SetFocusEventMode)
485 r.reportingFocus = true
486}
487
488func (r *standardRenderer) disableReportFocus() {
489 r.mtx.Lock()
490 defer r.mtx.Unlock()
491
492 r.execute(ansi.ResetFocusEventMode)
493 r.reportingFocus = false
494}
495
496func (r *standardRenderer) reportFocus() bool {
497 r.mtx.Lock()
498 defer r.mtx.Unlock()
499
500 return r.reportingFocus
501}
502
503// setWindowTitle sets the terminal window title.
504func (r *standardRenderer) setWindowTitle(title string) {
505 r.execute(ansi.SetWindowTitle(title))
506}
507
508// setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
509// renderer.
510func (r *standardRenderer) setIgnoredLines(from int, to int) {
511 // Lock if we're going to be clearing some lines since we don't want
512 // anything jacking our cursor.
513 if r.lastLinesRendered() > 0 {
514 r.mtx.Lock()
515 defer r.mtx.Unlock()
516 }
517
518 if r.ignoreLines == nil {
519 r.ignoreLines = make(map[int]struct{})
520 }
521 for i := from; i < to; i++ {
522 r.ignoreLines[i] = struct{}{}
523 }
524
525 // Erase ignored lines
526 lastLinesRendered := r.lastLinesRendered()
527 if lastLinesRendered > 0 {
528 buf := &bytes.Buffer{}
529
530 for i := lastLinesRendered - 1; i >= 0; i-- {
531 if _, exists := r.ignoreLines[i]; exists {
532 buf.WriteString(ansi.EraseEntireLine)
533 }
534 buf.WriteString(ansi.CUU1)
535 }
536 buf.WriteString(ansi.CursorPosition(0, lastLinesRendered)) // put cursor back
537 _, _ = r.out.Write(buf.Bytes())
538 }
539}
540
541// clearIgnoredLines returns control of any ignored lines to the standard
542// Bubble Tea renderer. That is, any lines previously set to be ignored can be
543// rendered to again.
544func (r *standardRenderer) clearIgnoredLines() {
545 r.ignoreLines = nil
546}
547
548// insertTop effectively scrolls up. It inserts lines at the top of a given
549// area designated to be a scrollable region, pushing everything else down.
550// This is roughly how ncurses does it.
551//
552// To call this function use command ScrollUp().
553//
554// For this to work renderer.ignoreLines must be set to ignore the scrollable
555// region since we are bypassing the normal Bubble Tea renderer here.
556//
557// Because this method relies on the terminal dimensions, it's only valid for
558// full-window applications (generally those that use the alternate screen
559// buffer).
560//
561// This method bypasses the normal rendering buffer and is philosophically
562// different than the normal way we approach rendering in Bubble Tea. It's for
563// use in high-performance rendering, such as a pager that could potentially
564// be rendering very complicated ansi. In cases where the content is simpler
565// standard Bubble Tea rendering should suffice.
566//
567// Deprecated: This option is deprecated and will be removed in a future
568// version of this package.
569func (r *standardRenderer) insertTop(lines []string, topBoundary, bottomBoundary int) {
570 r.mtx.Lock()
571 defer r.mtx.Unlock()
572
573 buf := &bytes.Buffer{}
574
575 buf.WriteString(ansi.SetTopBottomMargins(topBoundary, bottomBoundary))
576 buf.WriteString(ansi.CursorPosition(0, topBoundary))
577 buf.WriteString(ansi.InsertLine(len(lines)))
578 _, _ = buf.WriteString(strings.Join(lines, "\r\n"))
579 buf.WriteString(ansi.SetTopBottomMargins(0, r.height))
580
581 // Move cursor back to where the main rendering routine expects it to be
582 buf.WriteString(ansi.CursorPosition(0, r.lastLinesRendered()))
583
584 _, _ = r.out.Write(buf.Bytes())
585}
586
587// insertBottom effectively scrolls down. It inserts lines at the bottom of
588// a given area designated to be a scrollable region, pushing everything else
589// up. This is roughly how ncurses does it.
590//
591// To call this function use the command ScrollDown().
592//
593// See note in insertTop() for caveats, how this function only makes sense for
594// full-window applications, and how it differs from the normal way we do
595// rendering in Bubble Tea.
596//
597// Deprecated: This option is deprecated and will be removed in a future
598// version of this package.
599func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBoundary int) {
600 r.mtx.Lock()
601 defer r.mtx.Unlock()
602
603 buf := &bytes.Buffer{}
604
605 buf.WriteString(ansi.SetTopBottomMargins(topBoundary, bottomBoundary))
606 buf.WriteString(ansi.CursorPosition(0, bottomBoundary))
607 _, _ = buf.WriteString("\r\n" + strings.Join(lines, "\r\n"))
608 buf.WriteString(ansi.SetTopBottomMargins(0, r.height))
609
610 // Move cursor back to where the main rendering routine expects it to be
611 buf.WriteString(ansi.CursorPosition(0, r.lastLinesRendered()))
612
613 _, _ = r.out.Write(buf.Bytes())
614}
615
616// handleMessages handles internal messages for the renderer.
617func (r *standardRenderer) handleMessages(msg Msg) {
618 switch msg := msg.(type) {
619 case repaintMsg:
620 // Force a repaint by clearing the render cache as we slide into a
621 // render.
622 r.mtx.Lock()
623 r.repaint()
624 r.mtx.Unlock()
625
626 case WindowSizeMsg:
627 r.mtx.Lock()
628 r.width = msg.Width
629 r.height = msg.Height
630 r.repaint()
631 r.mtx.Unlock()
632
633 case clearScrollAreaMsg:
634 r.clearIgnoredLines()
635
636 // Force a repaint on the area where the scrollable stuff was in this
637 // update cycle
638 r.mtx.Lock()
639 r.repaint()
640 r.mtx.Unlock()
641
642 case syncScrollAreaMsg:
643 // Re-render scrolling area
644 r.clearIgnoredLines()
645 r.setIgnoredLines(msg.topBoundary, msg.bottomBoundary)
646 r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
647
648 // Force non-scrolling stuff to repaint in this update cycle
649 r.mtx.Lock()
650 r.repaint()
651 r.mtx.Unlock()
652
653 case scrollUpMsg:
654 r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
655
656 case scrollDownMsg:
657 r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
658
659 case printLineMessage:
660 if !r.altScreenActive {
661 lines := strings.Split(msg.messageBody, "\n")
662 r.mtx.Lock()
663 r.queuedMessageLines = append(r.queuedMessageLines, lines...)
664 r.repaint()
665 r.mtx.Unlock()
666 }
667 }
668}
669
670// HIGH-PERFORMANCE RENDERING STUFF
671
672type syncScrollAreaMsg struct {
673 lines []string
674 topBoundary int
675 bottomBoundary int
676}
677
678// SyncScrollArea performs a paint of the entire region designated to be the
679// scrollable area. This is required to initialize the scrollable region and
680// should also be called on resize (WindowSizeMsg).
681//
682// For high-performance, scroll-based rendering only.
683//
684// Deprecated: This option will be removed in a future version of this package.
685func SyncScrollArea(lines []string, topBoundary int, bottomBoundary int) Cmd {
686 return func() Msg {
687 return syncScrollAreaMsg{
688 lines: lines,
689 topBoundary: topBoundary,
690 bottomBoundary: bottomBoundary,
691 }
692 }
693}
694
695type clearScrollAreaMsg struct{}
696
697// ClearScrollArea deallocates the scrollable region and returns the control of
698// those lines to the main rendering routine.
699//
700// For high-performance, scroll-based rendering only.
701//
702// Deprecated: This option will be removed in a future version of this package.
703func ClearScrollArea() Msg {
704 return clearScrollAreaMsg{}
705}
706
707type scrollUpMsg struct {
708 lines []string
709 topBoundary int
710 bottomBoundary int
711}
712
713// ScrollUp adds lines to the top of the scrollable region, pushing existing
714// lines below down. Lines that are pushed out the scrollable region disappear
715// from view.
716//
717// For high-performance, scroll-based rendering only.
718//
719// Deprecated: This option will be removed in a future version of this package.
720func ScrollUp(newLines []string, topBoundary, bottomBoundary int) Cmd {
721 return func() Msg {
722 return scrollUpMsg{
723 lines: newLines,
724 topBoundary: topBoundary,
725 bottomBoundary: bottomBoundary,
726 }
727 }
728}
729
730type scrollDownMsg struct {
731 lines []string
732 topBoundary int
733 bottomBoundary int
734}
735
736// ScrollDown adds lines to the bottom of the scrollable region, pushing
737// existing lines above up. Lines that are pushed out of the scrollable region
738// disappear from view.
739//
740// For high-performance, scroll-based rendering only.
741//
742// Deprecated: This option will be removed in a future version of this package.
743func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
744 return func() Msg {
745 return scrollDownMsg{
746 lines: newLines,
747 topBoundary: topBoundary,
748 bottomBoundary: bottomBoundary,
749 }
750 }
751}
752
753type printLineMessage struct {
754 messageBody string
755}
756
757// Println prints above the Program. This output is unmanaged by the program and
758// will persist across renders by the Program.
759//
760// Unlike fmt.Println (but similar to log.Println) the message will be print on
761// its own line.
762//
763// If the altscreen is active no output will be printed.
764func Println(args ...interface{}) Cmd {
765 return func() Msg {
766 return printLineMessage{
767 messageBody: fmt.Sprint(args...),
768 }
769 }
770}
771
772// Printf prints above the Program. It takes a format template followed by
773// values similar to fmt.Printf. This output is unmanaged by the program and
774// will persist across renders by the Program.
775//
776// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
777// its own line.
778//
779// If the altscreen is active no output will be printed.
780func Printf(template string, args ...interface{}) Cmd {
781 return func() Msg {
782 return printLineMessage{
783 messageBody: fmt.Sprintf(template, args...),
784 }
785 }
786}