diff --git a/renderer.go b/renderer.go index 35449b4..1d8ed9c 100644 --- a/renderer.go +++ b/renderer.go @@ -3,26 +3,57 @@ package tea import ( "bytes" "io" + "strings" "sync" "time" - - "github.com/muesli/termenv" ) 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 // 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 { @@ -34,8 +65,17 @@ type renderer struct { 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, @@ -43,6 +83,7 @@ func newRenderer(out io.Writer) *renderer { } } +// start starts the renderer. func (r *renderer) start() { if r.ticker == nil { r.ticker = time.NewTicker(r.framerate) @@ -51,6 +92,7 @@ func (r *renderer) start() { go r.listen() } +// stop permanently halts the renderer. func (r *renderer) stop() { r.flush() r.done <- struct{}{} @@ -74,6 +116,7 @@ func (r *renderer) listen() { } } +// flush renders the buffer. func (r *renderer) flush() { if r.buf.Len() == 0 || r.buf.String() == r.lastRender { // Nothing to do @@ -84,7 +127,7 @@ func (r *renderer) flush() { // 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. + // 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 @@ -97,19 +140,32 @@ func (r *renderer) flush() { // 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 { - 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 - var out bytes.Buffer 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++ + } else if b == '\n' { out.Write([]byte("\r\n")) + r.linesRendered++ } else { _, _ = out.Write([]byte{b}) } @@ -120,9 +176,85 @@ func (r *renderer) flush() { r.buf.Reset() } -func (w *renderer) write(s string) { - w.mtx.Lock() - defer w.mtx.Unlock() - w.buf.Reset() - w.buf.WriteString(s) +// 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()) } diff --git a/screen.go b/screen.go new file mode 100644 index 0000000..a7aef75 --- /dev/null +++ b/screen.go @@ -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) +} diff --git a/tea.go b/tea.go index 25d503c..b137abe 100644 --- a/tea.go +++ b/tea.go @@ -2,8 +2,11 @@ package tea import ( "os" + "os/signal" + "syscall" "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 @@ -58,6 +61,13 @@ type quitMsg struct{} // can send a batchMsg with Batch. 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. func NewProgram(init Init, update Update, view View) *Program { return &Program{ @@ -70,13 +80,15 @@ 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{}) - mrRenderer = newRenderer(os.Stdout) + model Model + cmd Cmd + cmds = make(chan Cmd) + msgs = make(chan Msg) + errs = make(chan error) + done = make(chan struct{}) + + output *os.File = os.Stdout + mrRenderer = newRenderer(output) ) 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 go func() { for { @@ -141,6 +176,23 @@ func (p *Program) Start() error { 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 if batchedCmds, ok := msg.(batchMsg); ok { for _, cmd := range batchedCmds {