forked from Mirrors/bubbletea
Blind pass at adding high performance scrolling into the renderer
This commit is contained in:
parent
da86f9ac1a
commit
683473c26d
170
renderer.go
170
renderer.go
|
@ -3,26 +3,57 @@ package tea
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muesli/termenv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// BlankSymbol, in this case, is used to signal to the renderer to skip
|
|
||||||
// over a given cell and not perform any rendering on it. The const is
|
|
||||||
// literally the Unicode "BLANK SYMBOL" (U+2422).
|
|
||||||
//
|
|
||||||
// This character becomes useful when handing of portions of the screen to
|
|
||||||
// a separate renderer.
|
|
||||||
BlankSymbol = "␢"
|
|
||||||
|
|
||||||
// defaultFramerate specifies the maximum interval at which we should
|
// defaultFramerate specifies the maximum interval at which we should
|
||||||
// update the view.
|
// update the view.
|
||||||
defaultFramerate = time.Second / 60
|
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
|
// renderer is a timer-based renderer, updating the view at a given framerate
|
||||||
// to avoid overloading the terminal emulator.
|
// to avoid overloading the terminal emulator.
|
||||||
type renderer struct {
|
type renderer struct {
|
||||||
|
@ -34,8 +65,17 @@ type renderer struct {
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
lastRender string
|
lastRender string
|
||||||
linesRendered int
|
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 {
|
func newRenderer(out io.Writer) *renderer {
|
||||||
return &renderer{
|
return &renderer{
|
||||||
out: out,
|
out: out,
|
||||||
|
@ -43,6 +83,7 @@ func newRenderer(out io.Writer) *renderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// start starts the renderer.
|
||||||
func (r *renderer) start() {
|
func (r *renderer) start() {
|
||||||
if r.ticker == nil {
|
if r.ticker == nil {
|
||||||
r.ticker = time.NewTicker(r.framerate)
|
r.ticker = time.NewTicker(r.framerate)
|
||||||
|
@ -51,6 +92,7 @@ func (r *renderer) start() {
|
||||||
go r.listen()
|
go r.listen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stop permanently halts the renderer.
|
||||||
func (r *renderer) stop() {
|
func (r *renderer) stop() {
|
||||||
r.flush()
|
r.flush()
|
||||||
r.done <- struct{}{}
|
r.done <- struct{}{}
|
||||||
|
@ -74,6 +116,7 @@ func (r *renderer) listen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flush renders the buffer.
|
||||||
func (r *renderer) flush() {
|
func (r *renderer) flush() {
|
||||||
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
|
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
|
||||||
// Nothing to do
|
// Nothing to do
|
||||||
|
@ -84,7 +127,7 @@ func (r *renderer) flush() {
|
||||||
// and height, but this would mean a few things:
|
// and height, but this would mean a few things:
|
||||||
//
|
//
|
||||||
// 1) We'd need to maintain the terminal dimensions internally and listen
|
// 1) We'd need to maintain the terminal dimensions internally and listen
|
||||||
// for window size changes.
|
// for window size changes. [done]
|
||||||
//
|
//
|
||||||
// 2) We'd need to measure the width of lines, accounting for multi-cell
|
// 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
|
// rune widths, commonly found in Chinese, Japanese, Korean, emojis and so
|
||||||
|
@ -97,19 +140,32 @@ func (r *renderer) flush() {
|
||||||
// Because of the way this would complicate the renderer, this may not be
|
// Because of the way this would complicate the renderer, this may not be
|
||||||
// the place to do that.
|
// the place to do that.
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
|
||||||
r.mtx.Lock()
|
r.mtx.Lock()
|
||||||
defer r.mtx.Unlock()
|
defer r.mtx.Unlock()
|
||||||
|
|
||||||
if r.linesRendered > 0 {
|
if r.linesRendered > 0 {
|
||||||
termenv.ClearLines(r.linesRendered)
|
// 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
|
r.linesRendered = 0
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
for _, b := range r.buf.Bytes() {
|
for _, b := range r.buf.Bytes() {
|
||||||
if b == '\n' {
|
if _, exists := r.ignoreLines[r.linesRendered]; !exists {
|
||||||
|
cursorDown(out) // skip rendering for this line.
|
||||||
r.linesRendered++
|
r.linesRendered++
|
||||||
|
} else if b == '\n' {
|
||||||
out.Write([]byte("\r\n"))
|
out.Write([]byte("\r\n"))
|
||||||
|
r.linesRendered++
|
||||||
} else {
|
} else {
|
||||||
_, _ = out.Write([]byte{b})
|
_, _ = out.Write([]byte{b})
|
||||||
}
|
}
|
||||||
|
@ -120,9 +176,85 @@ func (r *renderer) flush() {
|
||||||
r.buf.Reset()
|
r.buf.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *renderer) write(s string) {
|
// write writes to the internal buffer. The buffer will be outputted via the
|
||||||
w.mtx.Lock()
|
// ticker which calls flush().
|
||||||
defer w.mtx.Unlock()
|
func (r *renderer) write(s string) {
|
||||||
w.buf.Reset()
|
r.mtx.Lock()
|
||||||
w.buf.WriteString(s)
|
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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
package tea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
te "github.com/muesli/termenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clearLine(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, te.CSI+te.EraseLineSeq, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cursorUp(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, te.CSI+te.CursorUpSeq, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cursorDown(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, te.CSI+te.CursorDownSeq, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertLine(w io.Writer, numLines int) {
|
||||||
|
fmt.Fprintf(w, te.CSI+"%dL", numLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveCursor(w io.Writer, row, col int) {
|
||||||
|
fmt.Fprintf(w, te.CSI+te.CursorPositionSeq, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCursorPosition(w io.Writer) {
|
||||||
|
fmt.Fprint(w, te.CSI+"s")
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreCursorPosition(w io.Writer) {
|
||||||
|
fmt.Fprint(w, te.CSI+"u")
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeScrollingRegion(w io.Writer, top, bottom int) {
|
||||||
|
fmt.Fprintf(w, te.CSI+"%d;%dr", top, bottom)
|
||||||
|
}
|
54
tea.go
54
tea.go
|
@ -2,8 +2,11 @@ package tea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/muesli/termenv"
|
"github.com/muesli/termenv"
|
||||||
|
"golang.org/x/crypto/ssh/terminal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Msg represents an action and is usually the result of an IO operation. It's
|
// Msg represents an action and is usually the result of an IO operation. It's
|
||||||
|
@ -58,6 +61,13 @@ type quitMsg struct{}
|
||||||
// can send a batchMsg with Batch.
|
// can send a batchMsg with Batch.
|
||||||
type batchMsg []Cmd
|
type batchMsg []Cmd
|
||||||
|
|
||||||
|
// WindowSizeMsg is used to report on the terminal size. It's fired once initially
|
||||||
|
// and then on every terminal resize.
|
||||||
|
type WindowSizeMsg struct {
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
// NewProgram creates a new Program.
|
// NewProgram creates a new Program.
|
||||||
func NewProgram(init Init, update Update, view View) *Program {
|
func NewProgram(init Init, update Update, view View) *Program {
|
||||||
return &Program{
|
return &Program{
|
||||||
|
@ -76,7 +86,9 @@ func (p *Program) Start() error {
|
||||||
msgs = make(chan Msg)
|
msgs = make(chan Msg)
|
||||||
errs = make(chan error)
|
errs = make(chan error)
|
||||||
done = make(chan struct{})
|
done = make(chan struct{})
|
||||||
mrRenderer = newRenderer(os.Stdout)
|
|
||||||
|
output *os.File = os.Stdout
|
||||||
|
mrRenderer = newRenderer(output)
|
||||||
)
|
)
|
||||||
|
|
||||||
err := initTerminal()
|
err := initTerminal()
|
||||||
|
@ -110,6 +122,29 @@ func (p *Program) Start() error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Get initial terminal size
|
||||||
|
go func() {
|
||||||
|
w, h, err := terminal.GetSize(int(output.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
msgs <- WindowSizeMsg{w, h}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Listen for window resizes
|
||||||
|
go func() {
|
||||||
|
sig := make(chan os.Signal)
|
||||||
|
signal.Notify(sig, syscall.SIGWINCH)
|
||||||
|
for {
|
||||||
|
<-sig
|
||||||
|
w, h, err := terminal.GetSize(int(output.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
msgs <- WindowSizeMsg{w, h}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Process commands
|
// Process commands
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
|
@ -141,6 +176,23 @@ func (p *Program) Start() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report resizes to the renderer. This only matters if we're doing
|
||||||
|
// higher performance scroll-based rendering.
|
||||||
|
if size, ok := msg.(WindowSizeMsg); ok {
|
||||||
|
mrRenderer.width = size.width
|
||||||
|
mrRenderer.height = size.height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages telling the renderer to ignore ranges of lines
|
||||||
|
if ignore, ok := msg.(IgnoreLinesMsg); ok {
|
||||||
|
mrRenderer.setIgnoredLines(ignore.from, ignore.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages telling the renderer to stop ignoring lines
|
||||||
|
if _, ok := msg.(IgnoreLinesMsg); ok {
|
||||||
|
mrRenderer.clearIgnoredLines()
|
||||||
|
}
|
||||||
|
|
||||||
// Process batch commands
|
// Process batch commands
|
||||||
if batchedCmds, ok := msg.(batchMsg); ok {
|
if batchedCmds, ok := msg.(batchMsg); ok {
|
||||||
for _, cmd := range batchedCmds {
|
for _, cmd := range batchedCmds {
|
||||||
|
|
Loading…
Reference in New Issue