From 87434a25698bf83df75bdacd584d0fac028419ea Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 5 Jun 2020 12:40:44 -0400 Subject: [PATCH] Buffer/ticker-based renderer --- examples/go.mod | 2 +- examples/go.sum | 4 ++ renderer.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++ tea.go | 47 ++++++----------------- 4 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 renderer.go diff --git a/examples/go.mod b/examples/go.mod index 1611667..3cefd9d 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -8,5 +8,5 @@ require ( github.com/charmbracelet/bubbles v0.0.0-20200526000837-87c7cd778f80 github.com/charmbracelet/bubbletea v0.6.4-0.20200525234836-3b8b011b5a26 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 - github.com/muesli/termenv v0.5.2 + github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 ) diff --git a/examples/go.sum b/examples/go.sum index 34d12a7..877e8a3 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -10,6 +10,8 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/muesli/termenv v0.5.2 h1:N1Y1dHRtx6OizOgaIQXd8SkJl4T/cCOV+YyWXiuLUEA= github.com/muesli/termenv v0.5.2/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= +github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 h1:AfshZBlqAwhCZ27NJ1aPlMcPBihF1squ1GpaollhLQk= +github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU= github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -22,4 +24,6 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtD golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/renderer.go b/renderer.go new file mode 100644 index 0000000..106a731 --- /dev/null +++ b/renderer.go @@ -0,0 +1,99 @@ +package tea + +import ( + "bytes" + "io" + "sync" + "time" + + "github.com/muesli/termenv" +) + +const ( + defaultFramerate = time.Millisecond * 16 +) + +type renderer struct { + out io.Writer + buf bytes.Buffer + framerate time.Duration + ticker *time.Ticker + mtx sync.Mutex + done chan struct{} + lastRender string + linesRendered int +} + +func newRenderer(out io.Writer) *renderer { + return &renderer{ + out: out, + framerate: defaultFramerate, + } +} + +func (r *renderer) start() { + if r.ticker == nil { + r.ticker = time.NewTicker(r.framerate) + } + r.done = make(chan struct{}) + go r.listen() +} + +func (r *renderer) stop() { + r.flush() + r.done <- struct{}{} +} + +func (r *renderer) listen() { + for { + select { + case <-r.ticker.C: + if r.ticker != nil { + r.flush() + } + case <-r.done: + r.mtx.Lock() + r.ticker.Stop() + r.ticker = nil + r.mtx.Unlock() + close(r.done) + return + } + } +} + +func (r *renderer) flush() { + if r.buf.Len() == 0 || r.buf.String() == r.lastRender { + // Nothing to do + return + } + + r.mtx.Lock() + defer r.mtx.Unlock() + + if r.linesRendered > 0 { + termenv.ClearLines(r.linesRendered) + } + r.linesRendered = 0 + + var out bytes.Buffer + for _, b := range r.buf.Bytes() { + if b == '\n' { + r.linesRendered++ + out.Write([]byte("\r\n")) + } else { + // TODO: don't write past the terminal width + _, _ = out.Write([]byte{b}) + } + } + + _, _ = r.out.Write(out.Bytes()) + r.lastRender = r.buf.String() + r.buf.Reset() +} + +func (w *renderer) write(s string) { + w.mtx.Lock() + defer w.mtx.Unlock() + w.buf.WriteString(s) +} diff --git a/tea.go b/tea.go index b25f904..bc44d79 100644 --- a/tea.go +++ b/tea.go @@ -1,9 +1,7 @@ package tea import ( - "io" "os" - "strings" "sync" "github.com/muesli/termenv" @@ -76,12 +74,13 @@ func NewProgram(init Init, update Update, view View) *Program { // Start initializes the program. func (p *Program) Start() error { var ( - model Model - cmd Cmd - cmds = make(chan Cmd) - msgs = make(chan Msg) - errs = make(chan error) - done = make(chan struct{}) + model Model + cmd Cmd + cmds = make(chan Cmd) + msgs = make(chan Msg) + errs = make(chan error) + done = make(chan struct{}) + mrRenderer = newRenderer(os.Stdout) ) err := initTerminal() @@ -98,8 +97,11 @@ func (p *Program) Start() error { }() } + // Start renderer + mrRenderer.start() + // Render initial view - p.render(model) + mrRenderer.write(p.view(model)) // Subscribe to user input go func() { @@ -152,36 +154,11 @@ func (p *Program) Start() error { model, cmd = p.update(msg, model) // run update cmds <- cmd // process command (if any) - p.render(model) // render to terminal + mrRenderer.write(p.view(model)) // send to renderer } } } -// Render a view to the terminal. Returns the number of lines rendered. -func (p *Program) render(model Model) { - view := p.view(model) - - // The view hasn't changed; no need to render - if view == p.currentRender { - return - } - - p.currentRender = view - linesRendered := strings.Count(p.currentRender, "\n") - - // Add carriage returns to ensure that the cursor travels to the start of a - // column after a newline. Keep in mind that this means that in the rest - // of the Tea program newlines should be a normal unix newline (\n). - view = strings.Replace(view, "\n", "\r\n", -1) - - p.mutex.Lock() - if linesRendered > 0 { - termenv.ClearLines(linesRendered) - } - _, _ = io.WriteString(os.Stdout, view) - p.mutex.Unlock() -} - // AltScreen exits the altscreen. This is just a wrapper around the termenv // function. func AltScreen() {