bubbletea/renderer.go
2020-06-16 22:30:47 -04:00

261 lines
7.2 KiB
Go

package tea
import (
"bytes"
"io"
"strings"
"sync"
"time"
)
const (
// defaultFramerate specifies the maximum interval at which we should
// update the view.
defaultFramerate = time.Second / 60
)
// RendererIgnoreLinesMsg tells the renderer to skip rendering for the given
// range of lines.
type IgnoreLinesMsg struct {
from int
to int
}
// IgnoreLines is a command that sets a range of lines to be ignored
// by the renderer. The general use case here is that those lines would be
// rendered separately for performance reasons.
func IgnoreLines(from int, to int) IgnoreLinesMsg {
return IgnoreLinesMsg{from: from, to: to}
}
// ClearIgnoredLinesMsg has the renderer allows the renderer to commence rendering
// any lines previously set to be ignored.
type ClearIgnoredLinesMsg struct{}
// RendererIgnoreLines is a command that sets a range of lines to be ignored
// by the renderer.
func ClearIgnoredLines(from int, to int) ClearIgnoredLinesMsg {
return ClearIgnoredLinesMsg{}
}
// ScrollDownMsg is experiemental. There are no guarantees about it persisting
// in a future API. It's exposed for high performance scrolling.
type ScrollUpMsg struct {
newLines []string
topBoundary int
bottomBoundary int
}
// ScrollDownMsg is experiemental. There are no guarantees about it persisting
// in a future API. It's exposed for high performance scrolling.
type ScrollDownMsg struct {
newLines []string
topBoundary int
bottomBoundary int
}
// renderer is a timer-based renderer, updating the view at a given framerate
// to avoid overloading the terminal emulator.
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
// renderer size; usually the size of the window
width int
height int
// lines not to render
ignoreLines map[int]struct{}
}
// newRenderer creates a new renderer. Normally you'll want to initialize it
// with os.Stdout as the argument.
func newRenderer(out io.Writer) *renderer {
return &renderer{
out: out,
framerate: defaultFramerate,
}
}
// start starts the renderer.
func (r *renderer) start() {
if r.ticker == nil {
r.ticker = time.NewTicker(r.framerate)
}
r.done = make(chan struct{})
go r.listen()
}
// stop permanently halts the renderer.
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
}
}
}
// flush renders the buffer.
func (r *renderer) flush() {
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
// Nothing to do
return
}
// We have an opportunity here to limit the rendering to the terminal width
// and height, but this would mean a few things:
//
// 1) We'd need to maintain the terminal dimensions internally and listen
// for window size changes. [done]
//
// 2) We'd need to measure the width of lines, accounting for multi-cell
// rune widths, commonly found in Chinese, Japanese, Korean, emojis and so
// on. We'd use something like go-runewidth
// (http://github.com/mattn/go-runewidth).
//
// 3) We'd need to measure the width of lines excluding ANSI escape
// sequences and break lines in the right places accordingly.
//
// Because of the way this would complicate the renderer, this may not be
// the place to do that.
out := new(bytes.Buffer)
r.mtx.Lock()
defer r.mtx.Unlock()
if r.linesRendered > 0 {
// Clear the lines we painted in the last render.
for i := r.linesRendered; i > 0; i-- {
// Check and see if we should skip rendering for this line. That
// includes clearing the line, which we normally do before a
// render.
if _, exists := r.ignoreLines[i]; !exists {
clearLine(out)
}
cursorUp(out)
}
}
r.linesRendered = 0
for _, b := range r.buf.Bytes() {
if _, exists := r.ignoreLines[r.linesRendered]; exists {
cursorDown(out) // skip rendering for this line.
r.linesRendered++
} else if b == '\n' {
out.Write([]byte("\r\n"))
r.linesRendered++
} else {
_, _ = out.Write([]byte{b})
}
}
_, _ = r.out.Write(out.Bytes())
r.lastRender = r.buf.String()
r.buf.Reset()
}
// write writes to the internal buffer. The buffer will be outputted via the
// ticker which calls flush().
func (r *renderer) write(s string) {
r.mtx.Lock()
defer r.mtx.Unlock()
r.buf.Reset()
_, _ = r.buf.WriteString(s)
}
// setIngoredLines speicifies lines not to be touched by the standard Bubble Tea
// renderer.
func (r *renderer) setIgnoredLines(from int, to int) {
for i := from; i < to; i++ {
r.ignoreLines[i] = struct{}{}
}
}
// clearIgnoredLines sets all lines to be rendered by the standard Bubble
// Tea renderer. Any lines previously set to be ignored can be rendered to
// again.
func (r *renderer) clearIgnoredLines() {
r.ignoreLines = make(map[int]struct{})
}
// insertTop effectively scrolls up. It inserts lines at the top of a given
// area designated to be a scrollable region, pushing everything else down.
// This is roughly how ncurses does it.
//
// For this to work renderer.ignoreLines must be set to ignore the scrollable
// region since we are bypassing the normal Bubble Tea renderer here.
//
// Because this method relies on the terminal dimensions, it's only valid for
// full-window applications (generally those that use the alternate screen
// buffer).
//
// This method bypasses the normal rendering buffer and is philisophically
// different than the normal way we approach rendering in Bubble Tea. It's for
// use in high-performance rendering, such as a pager that could potentially
// be rendering very complicated ansi. In cases where the content is simpler
// standard Bubble Tea rendering should suffice.
func (r *renderer) insertTop(lines []string, topBoundary, bottomBoundary int) {
r.mtx.Lock()
defer r.mtx.Unlock()
b := new(bytes.Buffer)
saveCursorPosition(b)
changeScrollingRegion(b, topBoundary, bottomBoundary)
moveCursor(b, topBoundary, 0)
insertLine(b, len(lines))
_, _ = io.WriteString(b, "\r\n"+strings.Join(lines, "\r\n"))
changeScrollingRegion(b, 0, r.height)
restoreCursorPosition(b)
r.out.Write(b.Bytes())
}
// insertBottom effectively scrolls down. It inserts lines at the bottom of
// a given area designated to be a scrollable region, pushing everything else
// up. This is roughly how ncurses does it.
//
// For this to work renderer.ignoreLines must be set to ignore the scrollable
// region since we are bypassing the normal Bubble Tea renderer here.
//
// See note in insertTop() on how this function only makes sense for
// full-window applications and how it differs from the noraml way we do
// rendering in Bubble Tea.
func (r *renderer) insertBottom(lines []string, topBoundary, bottomBoundary int) {
r.mtx.Lock()
defer r.mtx.Unlock()
b := new(bytes.Buffer)
saveCursorPosition(b)
changeScrollingRegion(b, topBoundary, bottomBoundary)
moveCursor(b, topBoundary, 0)
_, _ = io.WriteString(b, "\r\n"+strings.Join(lines, "\r\n"))
changeScrollingRegion(b, 0, r.height)
restoreCursorPosition(b)
r.out.Write(b.Bytes())
}