bubbletea v2.0.10 with demand-driven painting. upstream paints from a time.Ticker at the program's fps for as long as the program runs, so an idle TUI still wakes 60 times a second to flush a frame that did not change. here the frame writer sleeps until render() or execute() asks for a frame, then paints at most once per frame interval; the fps cap is unchanged while frames keep coming. a frame taller than the screen keeps only the rows on screen, instead of a slice that holds the tallest frame ever drawn alive. a command run with Exec hands its result to the model as the next message, before any input read once the terminal is back. upstream sends it from a goroutine, so a key typed as an editor closes could reach the model first and act on the state from before the edit. to bump bubbletea: copy the new release over third_party/bubbletea and apply this patch (patch -p1 -d third_party/bubbletea < third_party/bubbletea.patch). --- a/cursed_renderer.go +++ b/cursed_renderer.go @@ -345,9 +345,13 @@ content.Draw(s.cellbuf, s.cellbuf.Bounds()) // If the frame height is greater than the screen height, we drop the - // lines from the top of the buffer. + // lines from the top of the buffer. The kept lines are copied: a slice of + // the tall frame would keep every line of it alive, so the buffer would + // hold the tallest frame it ever drew for as long as the program runs. if frameHeight := frameArea.Dy(); frameHeight > s.height { - s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:] + kept := make([]uv.Line, s.height) + copy(kept, s.cellbuf.Lines[frameHeight-s.height:]) + s.cellbuf.Lines = kept } // Alt screen mode. --- a/tea.go +++ b/tea.go @@ -539,8 +539,9 @@ // modes keeps track of terminal modes that have been enabled or disabled. ignoreSignals uint32 - // ticker is the ticker that will be used to write to the renderer. - ticker *time.Ticker + // frames wakes the frame writer when there is something to paint. It + // replaces upstream's always-on ticker, so an idle program never wakes. + frames chan struct{} // once is used to stop the renderer. once sync.Once @@ -606,6 +607,7 @@ msgs: make(chan Msg), errs: make(chan error, 1), rendererDone: make(chan struct{}), + frames: make(chan struct{}, 1), } // Apply all options to the program. @@ -895,6 +897,16 @@ if p.renderer != nil { p.renderer.render(model.View()) // send view to renderer } + p.requestFrame() +} + +// requestFrame asks the frame writer to paint. A pending request already +// covers this one. +func (p *Program) requestFrame() { + select { + case p.frames <- struct{}{}: + default: + } } func (p *Program) execSequenceMsg(msg sequenceMsg) { @@ -1224,6 +1236,7 @@ p.mu.Lock() _, _ = p.outputBuf.WriteString(seq) p.mu.Unlock() + p.requestFrame() } // executeQuery writes a terminal query to the program output. Queries expect a @@ -1414,16 +1427,10 @@ } } -// startRenderer starts the renderer. +// startRenderer starts the renderer. It paints when asked to, at most once +// per frame, instead of on a fixed-rate ticker. 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. @@ -1431,17 +1438,28 @@ // Start the renderer. p.renderer.start() + p.requestFrame() // paint whatever was rendered while stopped go func() { + pace := time.NewTimer(framerate) + pace.Stop() + var last time.Time for { select { case <-p.rendererDone: - p.ticker.Stop() return - - case <-p.ticker.C: - _ = p.flush() - _ = p.renderer.flush(false) + case <-p.frames: + } + if wait := framerate - time.Since(last); wait > 0 { + pace.Reset(wait) + select { + case <-p.rendererDone: + return + case <-pace.C: + } } + _ = p.flush() + _ = p.renderer.flush(false) + last = time.Now() } }() } --- a/exec.go +++ b/exec.go @@ -98,14 +98,20 @@ } } -// exec runs an ExecCommand and delivers the results to the program as a Msg. -func (p *Program) exec(c ExecCommand, fn ExecCallback) { +// exec runs an ExecCommand and returns its callback's Msg, or nil without a +// callback. The event loop handles that Msg before any input read after the +// terminal comes back: sent through the queue instead, a key typed right after +// the command could reach the model ahead of the command's result. +func (p *Program) exec(c ExecCommand, fn ExecCallback) Msg { + result := func(err error) Msg { + if fn == nil { + return nil + } + return fn(err) + } if err := p.releaseTerminal(false); err != nil { // If we can't release input, abort. - if fn != nil { - go p.Send(fn(err)) - } - return + return result(err) } c.SetStdin(p.input) @@ -115,15 +121,9 @@ // Execute system command. if err := c.Run(); err != nil { _ = p.RestoreTerminal() // also try to restore the terminal. - if fn != nil { - go p.Send(fn(err)) - } - return + return result(err) } // Have the program re-capture input. - err := p.RestoreTerminal() - if fn != nil { - go p.Send(fn(err)) - } + return result(p.RestoreTerminal()) } --- a/tea.go +++ b/tea.go @@ -762,6 +762,15 @@ case msg := <-p.msgs: msg = p.translateInputEvent(msg) + // A command that takes the terminal runs here (NB: this blocks), + // and its result is handled as this message, so input read once + // the terminal is back cannot overtake it. + if exec, ok := msg.(execMsg); ok { + if result := p.exec(exec.cmd, exec.fn); result != nil { + msg = result + } + } + // Filter messages. if p.filter != nil { msg = p.filter(model, msg) @@ -837,10 +846,6 @@ case cursorColorMsg: p.executeQuery(ansi.RequestCursorColor) - - case execMsg: - // NB: this blocks. - p.exec(msg.cmd, msg.fn) case terminalVersion: p.executeQuery(ansi.RequestNameVersion)