From 6f7f9e64924f0ff5ac9b63c50251a421c3f374a2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 19 Aug 2024 15:28:02 -0400 Subject: [PATCH] feat: expose the renderer interface This adds the ability to use a custom renderer using a modified version of the existing `renderer` interface. --- nil_renderer.go | 27 +++--- nil_renderer_test.go | 19 ++-- renderer.go | 47 +++++----- screen.go | 14 +-- standard_renderer.go | 166 ++++++++++++----------------------- tea.go | 200 ++++++++++++++++++++++++++++--------------- tty.go | 12 +-- 7 files changed, 247 insertions(+), 238 deletions(-) diff --git a/nil_renderer.go b/nil_renderer.go index 19ad1c5a2b..89153251a1 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -2,15 +2,18 @@ package tea type nilRenderer struct{} -func (nilRenderer) start() {} -func (nilRenderer) stop() {} -func (nilRenderer) kill() {} -func (nilRenderer) write(string) {} -func (nilRenderer) repaint() {} -func (nilRenderer) clearScreen() {} -func (nilRenderer) altScreen() bool { return false } -func (nilRenderer) enterAltScreen() {} -func (nilRenderer) exitAltScreen() {} -func (nilRenderer) showCursor() {} -func (nilRenderer) hideCursor() {} -func (nilRenderer) execute(string) {} +var _ Renderer = nilRenderer{} + +func (nilRenderer) Flush() error { return nil } +func (nilRenderer) Close() error { return nil } +func (nilRenderer) Write([]byte) (int, error) { return 0, nil } +func (nilRenderer) WriteString(string) (int, error) { return 0, nil } +func (nilRenderer) Repaint() {} +func (nilRenderer) ClearScreen() {} +func (nilRenderer) AltScreen() bool { return false } +func (nilRenderer) EnterAltScreen() {} +func (nilRenderer) ExitAltScreen() {} +func (nilRenderer) CursorVisibility() bool { return false } +func (nilRenderer) ShowCursor() {} +func (nilRenderer) HideCursor() {} +func (nilRenderer) Execute(string) {} diff --git a/nil_renderer_test.go b/nil_renderer_test.go index f3db19547c..7fb6c0642c 100644 --- a/nil_renderer_test.go +++ b/nil_renderer_test.go @@ -4,18 +4,13 @@ import "testing" func TestNilRenderer(t *testing.T) { r := nilRenderer{} - r.start() - r.stop() - r.kill() - r.write("a") - r.repaint() - r.enterAltScreen() - if r.altScreen() { + r.Repaint() + r.EnterAltScreen() + if r.AltScreen() { t.Errorf("altScreen should always return false") } - r.exitAltScreen() - r.clearScreen() - r.showCursor() - r.hideCursor() - r.execute("") + r.ExitAltScreen() + r.ClearScreen() + r.ShowCursor() + r.HideCursor() } diff --git a/renderer.go b/renderer.go index a15f5ff69d..b8a8bd5d48 100644 --- a/renderer.go +++ b/renderer.go @@ -1,43 +1,46 @@ package tea -// renderer is the interface for Bubble Tea renderers. -type renderer interface { - // Start the renderer. - start() - - // Stop the renderer, but render the final frame in the buffer, if any. - stop() - - // Stop the renderer without doing any final rendering. - kill() +// Renderer is the interface for Bubble Tea renderers. +type Renderer interface { + // Close closes the renderer and flushes any remaining data. + Close() error // Write a frame to the renderer. The renderer can write this data to // output at its discretion. - write(string) + Write([]byte) (int, error) + + // WriteString a frame to the renderer. The renderer can WriteString this + // data to output at its discretion. + WriteString(string) (int, error) + + // Flush flushes the renderer's buffer to the output. + Flush() error // Request a full re-render. Note that this will not trigger a render // immediately. Rather, this method causes the next render to be a full - // repaint. Because of this, it's safe to call this method multiple times + // Repaint. Because of this, it's safe to call this method multiple times // in succession. - repaint() + Repaint() - // Clears the terminal. - clearScreen() + // ClearScreen clear the terminal screen. + ClearScreen() // Whether or not the alternate screen buffer is enabled. - altScreen() bool + AltScreen() bool // Enable the alternate screen buffer. - enterAltScreen() + EnterAltScreen() // Disable the alternate screen buffer. - exitAltScreen() + ExitAltScreen() + // CursorVisibility returns whether the cursor is visible. + CursorVisibility() bool // Show the cursor. - showCursor() + ShowCursor() // Hide the cursor. - hideCursor() + HideCursor() - // execute writes a sequence to the terminal. - execute(string) + // Execute writes a sequence to the underlying output. + Execute(string) } // repaintMsg forces a full repaint. diff --git a/screen.go b/screen.go index 895cb1cb31..22e440cefe 100644 --- a/screen.go +++ b/screen.go @@ -168,7 +168,7 @@ func DisabledReportFocus() Msg { return disableReportFocusMsg{} } // Deprecated: Use the WithAltScreen ProgramOption instead. func (p *Program) EnterAltScreen() { if p.renderer != nil { - p.renderer.enterAltScreen() + p.renderer.EnterAltScreen() } else { p.startupOptions |= withAltScreen } @@ -179,7 +179,7 @@ func (p *Program) EnterAltScreen() { // Deprecated: The altscreen will exited automatically when the program exits. func (p *Program) ExitAltScreen() { if p.renderer != nil { - p.renderer.exitAltScreen() + p.renderer.ExitAltScreen() } else { p.startupOptions &^= withAltScreen } @@ -191,7 +191,7 @@ func (p *Program) ExitAltScreen() { // Deprecated: Use the WithMouseCellMotion ProgramOption instead. func (p *Program) EnableMouseCellMotion() { if p.renderer != nil { - p.renderer.execute(ansi.EnableMouseCellMotion) + p.renderer.Execute(ansi.EnableMouseCellMotion) } else { p.startupOptions |= withMouseCellMotion } @@ -203,7 +203,7 @@ func (p *Program) EnableMouseCellMotion() { // Deprecated: The mouse will automatically be disabled when the program exits. func (p *Program) DisableMouseCellMotion() { if p.renderer != nil { - p.renderer.execute(ansi.DisableMouseCellMotion) + p.renderer.Execute(ansi.DisableMouseCellMotion) } else { p.startupOptions &^= withMouseCellMotion } @@ -216,7 +216,7 @@ func (p *Program) DisableMouseCellMotion() { // Deprecated: Use the WithMouseAllMotion ProgramOption instead. func (p *Program) EnableMouseAllMotion() { if p.renderer != nil { - p.renderer.execute(ansi.EnableMouseAllMotion) + p.renderer.Execute(ansi.EnableMouseAllMotion) } else { p.startupOptions |= withMouseAllMotion } @@ -228,7 +228,7 @@ func (p *Program) EnableMouseAllMotion() { // Deprecated: The mouse will automatically be disabled when the program exits. func (p *Program) DisableMouseAllMotion() { if p.renderer != nil { - p.renderer.execute(ansi.DisableMouseAllMotion) + p.renderer.Execute(ansi.DisableMouseAllMotion) } else { p.startupOptions &^= withMouseAllMotion } @@ -239,7 +239,7 @@ func (p *Program) DisableMouseAllMotion() { // Deprecated: Use the SetWindowTitle command instead. func (p *Program) SetWindowTitle(title string) { if p.renderer != nil { - p.renderer.execute(ansi.SetWindowTitle(title)) + p.renderer.Execute(ansi.SetWindowTitle(title)) } else { p.startupTitle = title } diff --git a/standard_renderer.go b/standard_renderer.go index a62dce2e90..7404b3af54 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -6,7 +6,6 @@ import ( "io" "strings" "sync" - "time" "github.com/charmbracelet/x/ansi" "github.com/muesli/ansi/compressor" @@ -30,13 +29,9 @@ type standardRenderer struct { buf bytes.Buffer queuedMessageLines []string - framerate time.Duration - ticker *time.Ticker - done chan struct{} lastRender string linesRendered int useANSICompressor bool - once sync.Once // cursor visibility state cursorHidden bool @@ -54,17 +49,10 @@ type standardRenderer struct { // newRenderer creates a new renderer. Normally you'll want to initialize it // with os.Stdout as the first argument. -func newRenderer(out io.Writer, useANSICompressor bool, fps int) renderer { - if fps < 1 { - fps = defaultFPS - } else if fps > maxFPS { - fps = maxFPS - } +func newRenderer(out io.Writer, useANSICompressor bool) Renderer { r := &standardRenderer{ out: out, mtx: &sync.Mutex{}, - done: make(chan struct{}), - framerate: time.Second / time.Duration(fps), useANSICompressor: useANSICompressor, queuedMessageLines: []string{}, } @@ -74,89 +62,31 @@ func newRenderer(out io.Writer, useANSICompressor bool, fps int) renderer { return r } -// start starts the renderer. -func (r *standardRenderer) start() { - if r.ticker == nil { - r.ticker = time.NewTicker(r.framerate) - } else { - // If the ticker already exists, it has been stopped and we need to - // reset it. - r.ticker.Reset(r.framerate) - } - - // Since the renderer can be restarted after a stop, we need to reset - // the done channel and its corresponding sync.Once. - r.once = sync.Once{} - - go r.listen() -} - -// stop permanently halts the renderer, rendering the final frame. -func (r *standardRenderer) stop() { - // Stop the renderer before acquiring the mutex to avoid a deadlock. - r.once.Do(func() { - r.done <- struct{}{} - }) - - // flush locks the mutex - r.flush() - +// Close closes the renderer and flushes any remaining data. +func (r *standardRenderer) Close() (err error) { r.mtx.Lock() defer r.mtx.Unlock() - r.execute(ansi.EraseEntireLine) + r.Execute(ansi.EraseEntireLine) // Move the cursor back to the beginning of the line - r.execute("\r") + r.Execute("\r") - if r.useANSICompressor { - if w, ok := r.out.(io.WriteCloser); ok { - _ = w.Close() - } - } + return } -// execute writes a sequence to the terminal. -func (r *standardRenderer) execute(seq string) { +// Execute writes a sequence to the terminal. +func (r *standardRenderer) Execute(seq string) { _, _ = io.WriteString(r.out, seq) } -// kill halts the renderer. The final frame will not be rendered. -func (r *standardRenderer) kill() { - // Stop the renderer before acquiring the mutex to avoid a deadlock. - r.once.Do(func() { - r.done <- struct{}{} - }) - - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.EraseEntireLine) - // Move the cursor back to the beginning of the line - r.execute("\r") -} - -// listen waits for ticks on the ticker, or a signal to stop the renderer. -func (r *standardRenderer) listen() { - for { - select { - case <-r.done: - r.ticker.Stop() - return - - case <-r.ticker.C: - r.flush() - } - } -} - -// flush renders the buffer. -func (r *standardRenderer) flush() { +// Flush renders the buffer. +func (r *standardRenderer) Flush() (err error) { r.mtx.Lock() defer r.mtx.Unlock() if r.buf.Len() == 0 || r.buf.String() == r.lastRender { // Nothing to do - return + return nil } // Output buffer @@ -275,14 +205,15 @@ func (r *standardRenderer) flush() { buf.WriteString(ansi.CursorLeft(r.width)) } - _, _ = r.out.Write(buf.Bytes()) + _, err = r.out.Write(buf.Bytes()) r.lastRender = r.buf.String() r.buf.Reset() + return } -// write writes to the internal buffer. The buffer will be outputted via the +// WriteString writes to the internal buffer. The buffer will be outputted via the // ticker which calls flush(). -func (r *standardRenderer) write(s string) { +func (r *standardRenderer) WriteString(s string) (int, error) { r.mtx.Lock() defer r.mtx.Unlock() r.buf.Reset() @@ -295,31 +226,38 @@ func (r *standardRenderer) write(s string) { s = " " } - _, _ = r.buf.WriteString(s) + return r.buf.WriteString(s) } -func (r *standardRenderer) repaint() { +// Write writes to the internal buffer. The buffer will be outputted via the +// ticker which calls flush(). +func (r *standardRenderer) Write(p []byte) (int, error) { + return r.WriteString(string(p)) +} + +// Repaint forces a full repaint. +func (r *standardRenderer) Repaint() { r.lastRender = "" } -func (r *standardRenderer) clearScreen() { +func (r *standardRenderer) ClearScreen() { r.mtx.Lock() defer r.mtx.Unlock() - r.execute(ansi.EraseEntireDisplay) - r.execute(ansi.MoveCursorOrigin) + r.Execute(ansi.EraseEntireDisplay) + r.Execute(ansi.MoveCursorOrigin) - r.repaint() + r.Repaint() } -func (r *standardRenderer) altScreen() bool { +func (r *standardRenderer) AltScreen() bool { r.mtx.Lock() defer r.mtx.Unlock() return r.altScreenActive } -func (r *standardRenderer) enterAltScreen() { +func (r *standardRenderer) EnterAltScreen() { r.mtx.Lock() defer r.mtx.Unlock() @@ -328,7 +266,7 @@ func (r *standardRenderer) enterAltScreen() { } r.altScreenActive = true - r.execute(ansi.EnableAltScreenBuffer) + r.Execute(ansi.EnableAltScreenBuffer) // Ensure that the terminal is cleared, even when it doesn't support // alt screen (or alt screen support is disabled, like GNU screen by @@ -336,22 +274,22 @@ func (r *standardRenderer) enterAltScreen() { // // Note: we can't use r.clearScreen() here because the mutex is already // locked. - r.execute(ansi.EraseEntireDisplay) - r.execute(ansi.MoveCursorOrigin) + r.Execute(ansi.EraseEntireDisplay) + r.Execute(ansi.MoveCursorOrigin) // cmd.exe and other terminals keep separate cursor states for the AltScreen // and the main buffer. We have to explicitly reset the cursor visibility // whenever we enter AltScreen. if r.cursorHidden { - r.execute(ansi.HideCursor) + r.Execute(ansi.HideCursor) } else { - r.execute(ansi.ShowCursor) + r.Execute(ansi.ShowCursor) } - r.repaint() + r.Repaint() } -func (r *standardRenderer) exitAltScreen() { +func (r *standardRenderer) ExitAltScreen() { r.mtx.Lock() defer r.mtx.Unlock() @@ -360,34 +298,38 @@ func (r *standardRenderer) exitAltScreen() { } r.altScreenActive = false - r.execute(ansi.DisableAltScreenBuffer) + r.Execute(ansi.DisableAltScreenBuffer) // cmd.exe and other terminals keep separate cursor states for the AltScreen // and the main buffer. We have to explicitly reset the cursor visibility // whenever we exit AltScreen. if r.cursorHidden { - r.execute(ansi.HideCursor) + r.Execute(ansi.HideCursor) } else { - r.execute(ansi.ShowCursor) + r.Execute(ansi.ShowCursor) } - r.repaint() + r.Repaint() +} + +func (r *standardRenderer) CursorVisibility() bool { + return !r.cursorHidden } -func (r *standardRenderer) showCursor() { +func (r *standardRenderer) ShowCursor() { r.mtx.Lock() defer r.mtx.Unlock() r.cursorHidden = false - r.execute(ansi.ShowCursor) + r.Execute(ansi.ShowCursor) } -func (r *standardRenderer) hideCursor() { +func (r *standardRenderer) HideCursor() { r.mtx.Lock() defer r.mtx.Unlock() r.cursorHidden = true - r.execute(ansi.HideCursor) + r.Execute(ansi.HideCursor) } // setIgnoredLines specifies lines not to be touched by the standard Bubble Tea @@ -504,14 +446,14 @@ func (r *standardRenderer) handleMessages(msg Msg) { // Force a repaint by clearing the render cache as we slide into a // render. r.mtx.Lock() - r.repaint() + r.Repaint() r.mtx.Unlock() case WindowSizeMsg: r.mtx.Lock() r.width = msg.Width r.height = msg.Height - r.repaint() + r.Repaint() r.mtx.Unlock() case clearScrollAreaMsg: @@ -520,7 +462,7 @@ func (r *standardRenderer) handleMessages(msg Msg) { // Force a repaint on the area where the scrollable stuff was in this // update cycle r.mtx.Lock() - r.repaint() + r.Repaint() r.mtx.Unlock() case syncScrollAreaMsg: @@ -531,7 +473,7 @@ func (r *standardRenderer) handleMessages(msg Msg) { // Force non-scrolling stuff to repaint in this update cycle r.mtx.Lock() - r.repaint() + r.Repaint() r.mtx.Unlock() case scrollUpMsg: @@ -545,7 +487,7 @@ func (r *standardRenderer) handleMessages(msg Msg) { lines := strings.Split(msg.messageBody, "\n") r.mtx.Lock() r.queuedMessageLines = append(r.queuedMessageLines, lines...) - r.repaint() + r.Repaint() r.mtx.Unlock() } } diff --git a/tea.go b/tea.go index cc1f7939cb..47d47f4a66 100644 --- a/tea.go +++ b/tea.go @@ -20,6 +20,7 @@ import ( "sync" "sync/atomic" "syscall" + "time" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" @@ -153,7 +154,7 @@ type Program struct { // ttyOutput is null if output is not a TTY. ttyOutput term.File previousOutputState *term.State - renderer renderer + renderer Renderer // the environment variables for the program, defaults to os.Environ(). environ []string @@ -178,6 +179,15 @@ type Program struct { // applicable, fps int + // ticker is the ticker that will be used to write to the renderer. + ticker *time.Ticker + + // once is used to stop the renderer. + once sync.Once + + // rendererDone is used to stop the renderer. + rendererDone chan struct{} + // kittyFlags stores kitty keyboard protocol progressive enhancement flags. kittyFlags int @@ -218,6 +228,7 @@ func NewProgram(model Model, opts ...ProgramOption) *Program { p := &Program{ initialModel: model, msgs: make(chan Msg), + rendererDone: make(chan struct{}), } // Apply all options to the program. @@ -243,6 +254,12 @@ func NewProgram(model Model, opts ...ProgramOption) *Program { p.environ = os.Environ() } + if p.fps < 1 { + p.fps = defaultFPS + } else if p.fps > maxFPS { + p.fps = maxFPS + } + return p } @@ -334,9 +351,9 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { } func (p *Program) disableMouse() { - p.renderer.execute(ansi.DisableMouseCellMotion) - p.renderer.execute(ansi.DisableMouseAllMotion) - p.renderer.execute(ansi.DisableMouseSgrExt) + p.renderer.Execute(ansi.DisableMouseCellMotion) + p.renderer.Execute(ansi.DisableMouseAllMotion) + p.renderer.Execute(ansi.DisableMouseSgrExt) } // eventLoop is the central message loop. It receives and handles the default @@ -370,82 +387,82 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { } case clearScreenMsg: - p.renderer.clearScreen() + p.renderer.ClearScreen() case enterAltScreenMsg: - p.renderer.enterAltScreen() + p.renderer.EnterAltScreen() case exitAltScreenMsg: - p.renderer.exitAltScreen() + p.renderer.ExitAltScreen() case enableMouseCellMotionMsg, enableMouseAllMotionMsg: switch msg.(type) { case enableMouseCellMotionMsg: - p.renderer.execute(ansi.EnableMouseCellMotion) + p.renderer.Execute(ansi.EnableMouseCellMotion) case enableMouseAllMotionMsg: - p.renderer.execute(ansi.EnableMouseAllMotion) + p.renderer.Execute(ansi.EnableMouseAllMotion) } // mouse mode (1006) is a no-op if the terminal doesn't support it. - p.renderer.execute(ansi.EnableMouseSgrExt) + p.renderer.Execute(ansi.EnableMouseSgrExt) case disableMouseMsg: p.disableMouse() case showCursorMsg: - p.renderer.showCursor() + p.renderer.ShowCursor() case hideCursorMsg: - p.renderer.hideCursor() + p.renderer.HideCursor() case enableBracketedPasteMsg: - p.renderer.execute(ansi.EnableBracketedPaste) + p.renderer.Execute(ansi.EnableBracketedPaste) p.bpActive = true case disableBracketedPasteMsg: - p.renderer.execute(ansi.DisableBracketedPaste) + p.renderer.Execute(ansi.DisableBracketedPaste) p.bpActive = false case enableReportFocusMsg: - p.renderer.execute(ansi.EnableReportFocus) + p.renderer.Execute(ansi.EnableReportFocus) case disableReportFocusMsg: - p.renderer.execute(ansi.DisableReportFocus) + p.renderer.Execute(ansi.DisableReportFocus) case readClipboardMsg: - p.renderer.execute(ansi.RequestSystemClipboard) + p.renderer.Execute(ansi.RequestSystemClipboard) case setClipboardMsg: - p.renderer.execute(ansi.SetSystemClipboard(string(msg))) + p.renderer.Execute(ansi.SetSystemClipboard(string(msg))) case readPrimaryClipboardMsg: - p.renderer.execute(ansi.RequestPrimaryClipboard) + p.renderer.Execute(ansi.RequestPrimaryClipboard) case setPrimaryClipboardMsg: - p.renderer.execute(ansi.SetPrimaryClipboard(string(msg))) + p.renderer.Execute(ansi.SetPrimaryClipboard(string(msg))) case setBackgroundColorMsg: if msg.Color != nil { - p.renderer.execute(ansi.SetBackgroundColor(msg.Color)) + p.renderer.Execute(ansi.SetBackgroundColor(msg.Color)) } case setForegroundColorMsg: if msg.Color != nil { - p.renderer.execute(ansi.SetForegroundColor(msg.Color)) + p.renderer.Execute(ansi.SetForegroundColor(msg.Color)) } case setCursorColorMsg: if msg.Color != nil { - p.renderer.execute(ansi.SetCursorColor(msg.Color)) + p.renderer.Execute(ansi.SetCursorColor(msg.Color)) } case backgroundColorMsg: - p.renderer.execute(ansi.RequestBackgroundColor) + p.renderer.Execute(ansi.RequestBackgroundColor) case foregroundColorMsg: - p.renderer.execute(ansi.RequestForegroundColor) + p.renderer.Execute(ansi.RequestForegroundColor) case cursorColorMsg: - p.renderer.execute(ansi.RequestCursorColor) + p.renderer.Execute(ansi.RequestCursorColor) case _KittyKeyboardMsg: // Store the kitty flags whenever they are queried. @@ -453,17 +470,17 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case setKittyKeyboardFlagsMsg: p.kittyFlags = int(msg) - p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + p.renderer.Execute(ansi.PushKittyKeyboard(p.kittyFlags)) case kittyKeyboardMsg: - p.renderer.execute(ansi.RequestKittyKeyboard) + p.renderer.Execute(ansi.RequestKittyKeyboard) case modifyOtherKeys: p.renderer.execute(ansi.RequestModifyOtherKeys) case setModifyOtherKeysMsg: p.modifyOtherKeys = int(msg) - p.renderer.execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) + p.renderer.Execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) case setEnhancedKeyboardMsg: if bool(msg) { @@ -473,15 +490,15 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.kittyFlags = 0 p.modifyOtherKeys = 0 } - p.renderer.execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) - p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + p.renderer.Execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) + p.renderer.Execute(ansi.PushKittyKeyboard(p.kittyFlags)) case enableWin32InputMsg: - p.renderer.execute(ansi.EnableWin32Input) + p.renderer.Execute(ansi.EnableWin32Input) p.win32Input = true case disableWin32InputMsg: - p.renderer.execute(ansi.DisableWin32Input) + p.renderer.Execute(ansi.DisableWin32Input) p.win32Input = false case execMsg: @@ -489,10 +506,10 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.exec(msg.cmd, msg.fn) case terminalVersion: - p.renderer.execute(ansi.RequestXTVersion) + p.renderer.Execute(ansi.RequestXTVersion) case primaryDeviceAttrsMsg: - p.renderer.execute(ansi.RequestPrimaryDeviceAttributes) + p.renderer.Execute(ansi.RequestPrimaryDeviceAttributes) case BatchMsg: for _, cmd := range msg { @@ -541,9 +558,9 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { } var cmd Cmd - model, cmd = model.Update(msg) // run update - cmds <- cmd // process command (if any) - p.renderer.write(model.View()) // send view to renderer + model, cmd = model.Update(msg) // run update + cmds <- cmd // process command (if any) + p.renderer.WriteString(model.View()) // send view to renderer } } } @@ -622,7 +639,7 @@ func (p *Program) Run() (Model, error) { // If no renderer is set use the standard one. if p.renderer == nil { - p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor), p.fps) + p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor)) } // Init the input reader and initial model. @@ -634,42 +651,42 @@ func (p *Program) Run() (Model, error) { } // Hide the cursor before starting the renderer. - p.renderer.hideCursor() + p.renderer.HideCursor() // Honor program startup options. if p.startupTitle != "" { - p.renderer.execute(ansi.SetWindowTitle(p.startupTitle)) + p.renderer.Execute(ansi.SetWindowTitle(p.startupTitle)) } if p.startupOptions&withAltScreen != 0 { - p.renderer.enterAltScreen() + p.renderer.EnterAltScreen() } if p.startupOptions&withoutBracketedPaste == 0 { - p.renderer.execute(ansi.EnableBracketedPaste) + p.renderer.Execute(ansi.EnableBracketedPaste) p.bpActive = true } if p.startupOptions&withMouseCellMotion != 0 { - p.renderer.execute(ansi.EnableMouseCellMotion) - p.renderer.execute(ansi.EnableMouseSgrExt) + p.renderer.Execute(ansi.EnableMouseCellMotion) + p.renderer.Execute(ansi.EnableMouseSgrExt) } else if p.startupOptions&withMouseAllMotion != 0 { - p.renderer.execute(ansi.EnableMouseAllMotion) - p.renderer.execute(ansi.EnableMouseSgrExt) + p.renderer.Execute(ansi.EnableMouseAllMotion) + p.renderer.Execute(ansi.EnableMouseSgrExt) } if p.startupOptions&withModifyOtherKeys != 0 { - p.renderer.execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) + p.renderer.Execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) } if p.startupOptions&withKittyKeyboard != 0 { - p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + p.renderer.Execute(ansi.PushKittyKeyboard(p.kittyFlags)) } if p.startupOptions&withReportFocus != 0 { - p.renderer.execute(ansi.EnableReportFocus) + p.renderer.Execute(ansi.EnableReportFocus) } if p.startupOptions&withWindowsInputMode != 0 { - p.renderer.execute(ansi.EnableWin32Input) + p.renderer.Execute(ansi.EnableWin32Input) } // Start the renderer. - p.renderer.start() + p.startRenderer() // Initialize the program. if initCmd := model.Init(); initCmd != nil { @@ -687,7 +704,7 @@ func (p *Program) Run() (Model, error) { } // Render the initial view. - p.renderer.write(model.View()) + p.renderer.WriteString(model.View()) //nolint:errcheck // Handle resize events. handlers.add(p.handleResize()) @@ -702,7 +719,7 @@ func (p *Program) Run() (Model, error) { err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err()) } else { // Ensure we rendered the final state of the model. - p.renderer.write(model.View()) + p.renderer.WriteString(model.View()) //nolint:errcheck } // Tear down. @@ -786,11 +803,7 @@ func (p *Program) Wait() { // to its original state. func (p *Program) shutdown(kill bool) { if p.renderer != nil { - if kill { - p.renderer.kill() - } else { - p.renderer.stop() - } + p.stopRenderer(kill) } _ = p.restoreTerminalState() @@ -808,8 +821,8 @@ func (p *Program) ReleaseTerminal() error { p.waitForReadLoop() if p.renderer != nil { - p.renderer.stop() - p.altScreenWasActive = p.renderer.altScreen() + p.stopRenderer(false) + p.altScreenWasActive = p.renderer.AltScreen() } return p.restoreTerminalState() @@ -828,21 +841,21 @@ func (p *Program) RestoreTerminal() error { return err } if p.altScreenWasActive { - p.renderer.enterAltScreen() + p.renderer.EnterAltScreen() } else { // entering alt screen already causes a repaint. go p.Send(repaintMsg{}) } if p.renderer != nil { - p.renderer.start() - p.renderer.hideCursor() - p.renderer.execute(ansi.EnableBracketedPaste) + p.startRenderer() + p.renderer.HideCursor() + p.renderer.Execute(ansi.EnableBracketedPaste) p.bpActive = true if p.modifyOtherKeys != 0 { - p.renderer.execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) + p.renderer.Execute(ansi.ModifyOtherKeys(p.modifyOtherKeys)) } if p.kittyFlags != 0 { - p.renderer.execute(ansi.PushKittyKeyboard(p.kittyFlags)) + p.renderer.Execute(ansi.PushKittyKeyboard(p.kittyFlags)) } } @@ -878,3 +891,56 @@ func (p *Program) Printf(template string, args ...interface{}) { messageBody: fmt.Sprintf(template, args...), } } + +// startRenderer starts the renderer. +func (p *Program) startRenderer() { + framerate := time.Second / time.Duration(p.fps) + if p.ticker == nil { + p.ticker = time.NewTicker(framerate) + } else { + // If the ticker already exists, it has been stopped and we need to + // reset it. + p.ticker.Reset(framerate) + } + + // Since the renderer can be restarted after a stop, we need to reset + // the done channel and its corresponding sync.Once. + p.once = sync.Once{} + + // Start the renderer. + go func() { + for { + select { + case <-p.rendererDone: + p.ticker.Stop() + return + + case <-p.ticker.C: + p.renderer.Flush() //nolint:errcheck + } + } + }() +} + +// stopRenderer stops the renderer. +// If kill is true, the renderer will be stopped immediately without flushing +// the last frame. +func (p *Program) stopRenderer(kill bool) { + // Stop the renderer before acquiring the mutex to avoid a deadlock. + p.once.Do(func() { + p.rendererDone <- struct{}{} + }) + + if !kill { + // flush locks the mutex + p.renderer.Flush() //nolint:errcheck + } + + p.renderer.Close() //nolint:errcheck + + if !kill && p.startupOptions.has(withANSICompressor) { + if w, ok := p.output.(io.WriteCloser); ok { + _ = w.Close() + } + } +} diff --git a/tty.go b/tty.go index 116f598f15..a74ca8dffb 100644 --- a/tty.go +++ b/tty.go @@ -33,19 +33,19 @@ func (p *Program) initTerminal() error { // Bubble Tea program. func (p *Program) restoreTerminalState() error { if p.renderer != nil { - p.renderer.execute(ansi.DisableBracketedPaste) + p.renderer.Execute(ansi.DisableBracketedPaste) p.bpActive = false - p.renderer.showCursor() + p.renderer.ShowCursor() p.disableMouse() if p.modifyOtherKeys != 0 { - p.renderer.execute(ansi.DisableModifyOtherKeys) + p.renderer.Execute(ansi.DisableModifyOtherKeys) } if p.kittyFlags != 0 { - p.renderer.execute(ansi.DisableKittyKeyboard) + p.renderer.Execute(ansi.DisableKittyKeyboard) } - if p.renderer.altScreen() { - p.renderer.exitAltScreen() + if p.renderer.AltScreen() { + p.renderer.ExitAltScreen() // give the terminal a moment to catch up time.Sleep(time.Millisecond * 10) //nolint:gomnd