forked from Mirrors/bubbletea
Add nil renderer and combination TUI-daemon program example
The Nil Renderer essentially disables the Bubble Tea renderer sending loggings and print statements to stdout. It can be enabled via the ProgramOption WithoutRenderer.
This commit is contained in:
parent
2b8b95d8f8
commit
b3f62af8b5
|
@ -8,6 +8,7 @@ require (
|
||||||
github.com/charmbracelet/glamour v0.2.0
|
github.com/charmbracelet/glamour v0.2.0
|
||||||
github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776
|
github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776
|
||||||
github.com/lucasb-eyer/go-colorful v1.0.3
|
github.com/lucasb-eyer/go-colorful v1.0.3
|
||||||
|
github.com/mattn/go-isatty v0.0.12
|
||||||
github.com/mattn/go-runewidth v0.0.10
|
github.com/mattn/go-runewidth v0.0.10
|
||||||
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
|
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
|
||||||
github.com/muesli/termenv v0.7.4
|
github.com/muesli/termenv v0.7.4
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
"github.com/muesli/reflow/indent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rand.Seed(time.Now().UTC().UnixNano())
|
||||||
|
|
||||||
|
var (
|
||||||
|
daemonMode bool
|
||||||
|
showHelp bool
|
||||||
|
opts []tea.ProgramOption
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.BoolVar(&daemonMode, "d", false, "run as a daemon")
|
||||||
|
flag.BoolVar(&showHelp, "h", false, "show help")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if showHelp {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if daemonMode || !isatty.IsTerminal(os.Stdout.Fd()) {
|
||||||
|
// If we're in daemon mode don't render the TUI
|
||||||
|
opts = []tea.ProgramOption{tea.WithoutRenderer()}
|
||||||
|
} else {
|
||||||
|
// If we're in TUI mode, discard log output
|
||||||
|
log.SetOutput(ioutil.Discard)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := tea.NewProgram(newModel(), opts...)
|
||||||
|
if err := p.Start(); err != nil {
|
||||||
|
fmt.Println("Error starting Bubble Tea program:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
spinner spinner.Model
|
||||||
|
results []time.Duration
|
||||||
|
quitting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModel() model {
|
||||||
|
const showLastResults = 5
|
||||||
|
|
||||||
|
return model{
|
||||||
|
spinner: spinner.NewModel(),
|
||||||
|
results: make([]time.Duration, showLastResults),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd {
|
||||||
|
log.Println("Starting work...")
|
||||||
|
return tea.Batch(
|
||||||
|
spinner.Tick,
|
||||||
|
runPretendProcess,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
case spinner.TickMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
case processFinishedMsg:
|
||||||
|
d := time.Duration(msg)
|
||||||
|
log.Printf("Finished job in %s", d)
|
||||||
|
m.results = append(m.results[1:], d)
|
||||||
|
return m, runPretendProcess
|
||||||
|
default:
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
s := "\n" + m.spinner.View() + " Doing some work...\n\n"
|
||||||
|
|
||||||
|
for _, dur := range m.results {
|
||||||
|
if dur == 0 {
|
||||||
|
s += ".....................\n"
|
||||||
|
} else {
|
||||||
|
s += fmt.Sprintf("Job finished in %s\n", dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s += "\nPress any key to exit\n"
|
||||||
|
|
||||||
|
if m.quitting {
|
||||||
|
s += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return indent.String(s, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFinishedMsg is send when a pretend process completes.
|
||||||
|
type processFinishedMsg time.Duration
|
||||||
|
|
||||||
|
// pretendProcess simulates a long-running process.
|
||||||
|
func runPretendProcess() tea.Msg {
|
||||||
|
pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond
|
||||||
|
time.Sleep(pause)
|
||||||
|
return processFinishedMsg(pause)
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package tea
|
||||||
|
|
||||||
|
type nilRenderer struct{}
|
||||||
|
|
||||||
|
func (n nilRenderer) start() {}
|
||||||
|
func (n nilRenderer) stop() {}
|
||||||
|
func (n nilRenderer) write(v string) {}
|
||||||
|
func (n nilRenderer) altScreen() bool { return false }
|
||||||
|
func (n nilRenderer) setAltScreen(v bool) {}
|
393
renderer.go
393
renderer.go
|
@ -1,390 +1,9 @@
|
||||||
package tea
|
package tea
|
||||||
|
|
||||||
import (
|
type renderer interface {
|
||||||
"bytes"
|
start()
|
||||||
"io"
|
stop()
|
||||||
"strings"
|
write(string)
|
||||||
"sync"
|
altScreen() bool
|
||||||
"time"
|
setAltScreen(bool)
|
||||||
|
|
||||||
"github.com/muesli/reflow/truncate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// defaultFramerate specifies the maximum interval at which we should
|
|
||||||
// update the view.
|
|
||||||
defaultFramerate = time.Second / 60
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderer is a timer-based renderer, updating the view at a given framerate
|
|
||||||
// to avoid overloading the terminal emulator.
|
|
||||||
//
|
|
||||||
// In cases where very high performance is needed the renderer can be told
|
|
||||||
// to exclude ranges of lines, allowing them to be written to directly.
|
|
||||||
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
|
|
||||||
|
|
||||||
// essentially whether or not we're using the full size of the terminal
|
|
||||||
altScreenActive bool
|
|
||||||
|
|
||||||
// renderer dimensions; 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 first argument.
|
|
||||||
func newRenderer(out io.Writer, mtx *sync.Mutex) *renderer {
|
|
||||||
return &renderer{
|
|
||||||
out: out,
|
|
||||||
mtx: mtx,
|
|
||||||
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{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// listen waits for ticks on the ticker, or a signal to stop the renderer.
|
|
||||||
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() {
|
|
||||||
r.mtx.Lock()
|
|
||||||
defer r.mtx.Unlock()
|
|
||||||
|
|
||||||
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
|
|
||||||
// Nothing to do
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
|
|
||||||
// Clear any lines we painted in the last render.
|
|
||||||
if r.linesRendered > 0 {
|
|
||||||
for i := r.linesRendered - 1; i > 0; i-- {
|
|
||||||
// Check if we should skip rendering for this line. Clearing the
|
|
||||||
// line before painting is part of the standard rendering routine.
|
|
||||||
if _, exists := r.ignoreLines[i]; !exists {
|
|
||||||
clearLine(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
cursorUp(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := r.ignoreLines[0]; !exists {
|
|
||||||
// We need to return to the start of the line here to properly
|
|
||||||
// erase it. Going back the entire width of the terminal will
|
|
||||||
// usually be farther than we need to go, but terminal emulators
|
|
||||||
// will stop the cursor at the start of the line as a rule.
|
|
||||||
//
|
|
||||||
// We use this sequence in particular because it's part of the ANSI
|
|
||||||
// standard (whereas others are proprietary to, say, VT100/VT52).
|
|
||||||
// If cursor previous line (ESC[ + <n> + F) were better supported
|
|
||||||
// we could use that above to eliminate this step.
|
|
||||||
cursorBack(out, r.width)
|
|
||||||
clearLine(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.linesRendered = 0
|
|
||||||
lines := strings.Split(r.buf.String(), "\n")
|
|
||||||
|
|
||||||
// Paint new lines
|
|
||||||
for i := 0; i < len(lines); i++ {
|
|
||||||
if _, exists := r.ignoreLines[r.linesRendered]; exists {
|
|
||||||
cursorDown(out) // skip rendering for this line.
|
|
||||||
} else {
|
|
||||||
line := lines[i]
|
|
||||||
|
|
||||||
// Truncate lines wider than the width of the window to avoid
|
|
||||||
// rendering troubles. If we don't have the width of the window
|
|
||||||
// this will be ignored.
|
|
||||||
//
|
|
||||||
// Note that on Windows we can't get the width of the window
|
|
||||||
// (signal SIGWINCH is not supported), so this will be ignored.
|
|
||||||
if r.width > 0 {
|
|
||||||
line = truncate.String(line, uint(r.width))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.WriteString(out, line)
|
|
||||||
|
|
||||||
if i != len(lines)-1 {
|
|
||||||
_, _ = io.WriteString(out, "\r\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.linesRendered++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the cursor is at the start of the last line to keep rendering
|
|
||||||
// behavior consistent.
|
|
||||||
if r.altScreenActive {
|
|
||||||
// We need this case to fix a bug in macOS terminal. In other terminals
|
|
||||||
// the below case seems to do the job regardless of whether or not we're
|
|
||||||
// using the full terminal window.
|
|
||||||
moveCursor(out, r.linesRendered, 0)
|
|
||||||
} else {
|
|
||||||
cursorBack(out, r.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
|
|
||||||
// renderer.
|
|
||||||
func (r *renderer) setIgnoredLines(from int, to int) {
|
|
||||||
// Lock if we're going to be clearing some lines since we don't want
|
|
||||||
// anything jacking our cursor.
|
|
||||||
if r.linesRendered > 0 {
|
|
||||||
r.mtx.Lock()
|
|
||||||
defer r.mtx.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.ignoreLines == nil {
|
|
||||||
r.ignoreLines = make(map[int]struct{})
|
|
||||||
}
|
|
||||||
for i := from; i < to; i++ {
|
|
||||||
r.ignoreLines[i] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erase ignored lines
|
|
||||||
if r.linesRendered > 0 {
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
for i := r.linesRendered - 1; i >= 0; i-- {
|
|
||||||
if _, exists := r.ignoreLines[i]; exists {
|
|
||||||
clearLine(out)
|
|
||||||
}
|
|
||||||
cursorUp(out)
|
|
||||||
}
|
|
||||||
moveCursor(out, r.linesRendered, 0) // put cursor back
|
|
||||||
_, _ = r.out.Write(out.Bytes())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearIgnoredLines returns control of any ignored lines to the standard
|
|
||||||
// Bubble Tea renderer. That is, any lines previously set to be ignored can be
|
|
||||||
// rendered to again.
|
|
||||||
func (r *renderer) clearIgnoredLines() {
|
|
||||||
r.ignoreLines = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// To call this function use command ScrollUp().
|
|
||||||
//
|
|
||||||
// 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 philosophically
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
changeScrollingRegion(b, topBoundary, bottomBoundary)
|
|
||||||
moveCursor(b, topBoundary, 0)
|
|
||||||
insertLine(b, len(lines))
|
|
||||||
_, _ = io.WriteString(b, strings.Join(lines, "\r\n"))
|
|
||||||
changeScrollingRegion(b, 0, r.height)
|
|
||||||
|
|
||||||
// Move cursor back to where the main rendering routine expects it to be
|
|
||||||
moveCursor(b, r.linesRendered, 0)
|
|
||||||
|
|
||||||
_, _ = 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.
|
|
||||||
//
|
|
||||||
// To call this function use the command ScrollDown().
|
|
||||||
//
|
|
||||||
// See note in insertTop() for caveats, how this function only makes sense for
|
|
||||||
// full-window applications, and how it differs from the normal 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)
|
|
||||||
|
|
||||||
changeScrollingRegion(b, topBoundary, bottomBoundary)
|
|
||||||
moveCursor(b, bottomBoundary, 0)
|
|
||||||
_, _ = io.WriteString(b, "\r\n"+strings.Join(lines, "\r\n"))
|
|
||||||
changeScrollingRegion(b, 0, r.height)
|
|
||||||
|
|
||||||
// Move cursor back to where the main rendering routine expects it to be
|
|
||||||
moveCursor(b, r.linesRendered, 0)
|
|
||||||
|
|
||||||
_, _ = r.out.Write(b.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMessages handles internal messages for the renderer.
|
|
||||||
func (r *renderer) handleMessages(msg Msg) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case WindowSizeMsg:
|
|
||||||
r.width = msg.Width
|
|
||||||
r.height = msg.Height
|
|
||||||
|
|
||||||
case clearScrollAreaMsg:
|
|
||||||
r.clearIgnoredLines()
|
|
||||||
|
|
||||||
// Force a repaint on the area where the scrollable stuff was in this
|
|
||||||
// update cycle
|
|
||||||
r.mtx.Lock()
|
|
||||||
r.lastRender = ""
|
|
||||||
r.mtx.Unlock()
|
|
||||||
|
|
||||||
case syncScrollAreaMsg:
|
|
||||||
// Re-render scrolling area
|
|
||||||
r.clearIgnoredLines()
|
|
||||||
r.setIgnoredLines(msg.topBoundary, msg.bottomBoundary)
|
|
||||||
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
|
||||||
|
|
||||||
// Force non-scrolling stuff to repaint in this update cycle
|
|
||||||
r.mtx.Lock()
|
|
||||||
r.lastRender = ""
|
|
||||||
r.mtx.Unlock()
|
|
||||||
|
|
||||||
case scrollUpMsg:
|
|
||||||
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
|
||||||
|
|
||||||
case scrollDownMsg:
|
|
||||||
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HIGH-PERFORMANCE RENDERING STUFF
|
|
||||||
|
|
||||||
type syncScrollAreaMsg struct {
|
|
||||||
lines []string
|
|
||||||
topBoundary int
|
|
||||||
bottomBoundary int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncScrollArea performs a paint of the entire region designated to be the
|
|
||||||
// scrollable area. This is required to initialize the scrollable region and
|
|
||||||
// should also be called on resize (WindowSizeMsg).
|
|
||||||
//
|
|
||||||
// For high-performance, scroll-based rendering only.
|
|
||||||
func SyncScrollArea(lines []string, topBoundary int, bottomBoundary int) Cmd {
|
|
||||||
return func() Msg {
|
|
||||||
return syncScrollAreaMsg{
|
|
||||||
lines: lines,
|
|
||||||
topBoundary: topBoundary,
|
|
||||||
bottomBoundary: bottomBoundary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type clearScrollAreaMsg struct{}
|
|
||||||
|
|
||||||
// ClearScrollArea deallocates the scrollable region and returns the control of
|
|
||||||
// those lines to the main rendering routine.
|
|
||||||
//
|
|
||||||
// For high-performance, scroll-based rendering only.
|
|
||||||
func ClearScrollArea() Msg {
|
|
||||||
return clearScrollAreaMsg{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type scrollUpMsg struct {
|
|
||||||
lines []string
|
|
||||||
topBoundary int
|
|
||||||
bottomBoundary int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScrollUp adds lines to the top of the scrollable region, pushing existing
|
|
||||||
// lines below down. Lines that are pushed out the scrollable region disappear
|
|
||||||
// from view.
|
|
||||||
//
|
|
||||||
// For high-performance, scroll-based rendering only.
|
|
||||||
func ScrollUp(newLines []string, topBoundary, bottomBoundary int) Cmd {
|
|
||||||
return func() Msg {
|
|
||||||
return scrollUpMsg{
|
|
||||||
lines: newLines,
|
|
||||||
topBoundary: topBoundary,
|
|
||||||
bottomBoundary: bottomBoundary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type scrollDownMsg struct {
|
|
||||||
lines []string
|
|
||||||
topBoundary int
|
|
||||||
bottomBoundary int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScrollDown adds lines to the bottom of the scrollable region, pushing
|
|
||||||
// existing lines above up. Lines that are pushed out of the scrollable region
|
|
||||||
// disappear from view.
|
|
||||||
//
|
|
||||||
// For high-performance, scroll-based rendering only.
|
|
||||||
func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
|
|
||||||
return func() Msg {
|
|
||||||
return scrollDownMsg{
|
|
||||||
lines: newLines,
|
|
||||||
topBoundary: topBoundary,
|
|
||||||
bottomBoundary: bottomBoundary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,398 @@
|
||||||
|
package tea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/muesli/reflow/truncate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultFramerate specifies the maximum interval at which we should
|
||||||
|
// update the view.
|
||||||
|
defaultFramerate = time.Second / 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderer is a timer-based renderer, updating the view at a given framerate
|
||||||
|
// to avoid overloading the terminal emulator.
|
||||||
|
//
|
||||||
|
// In cases where very high performance is needed the renderer can be told
|
||||||
|
// to exclude ranges of lines, allowing them to be written to directly.
|
||||||
|
type standardRenderer struct {
|
||||||
|
out io.Writer
|
||||||
|
buf bytes.Buffer
|
||||||
|
framerate time.Duration
|
||||||
|
ticker *time.Ticker
|
||||||
|
mtx *sync.Mutex
|
||||||
|
done chan struct{}
|
||||||
|
lastRender string
|
||||||
|
linesRendered int
|
||||||
|
|
||||||
|
// essentially whether or not we're using the full size of the terminal
|
||||||
|
altScreenActive bool
|
||||||
|
|
||||||
|
// renderer dimensions; 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 first argument.
|
||||||
|
func newRenderer(out io.Writer, mtx *sync.Mutex) renderer {
|
||||||
|
return &standardRenderer{
|
||||||
|
out: out,
|
||||||
|
mtx: mtx,
|
||||||
|
framerate: defaultFramerate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start starts the renderer.
|
||||||
|
func (r *standardRenderer) 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 *standardRenderer) stop() {
|
||||||
|
r.flush()
|
||||||
|
r.done <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listen waits for ticks on the ticker, or a signal to stop the renderer.
|
||||||
|
func (r *standardRenderer) 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 *standardRenderer) flush() {
|
||||||
|
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
|
||||||
|
// Nothing to do
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
|
||||||
|
r.mtx.Lock()
|
||||||
|
defer r.mtx.Unlock()
|
||||||
|
|
||||||
|
// Clear any lines we painted in the last render.
|
||||||
|
if r.linesRendered > 0 {
|
||||||
|
for i := r.linesRendered - 1; i > 0; i-- {
|
||||||
|
// Check if we should skip rendering for this line. Clearing the
|
||||||
|
// line before painting is part of the standard rendering routine.
|
||||||
|
if _, exists := r.ignoreLines[i]; !exists {
|
||||||
|
clearLine(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorUp(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := r.ignoreLines[0]; !exists {
|
||||||
|
// We need to return to the start of the line here to properly
|
||||||
|
// erase it. Going back the entire width of the terminal will
|
||||||
|
// usually be farther than we need to go, but terminal emulators
|
||||||
|
// will stop the cursor at the start of the line as a rule.
|
||||||
|
//
|
||||||
|
// We use this sequence in particular because it's part of the ANSI
|
||||||
|
// standard (whereas others are proprietary to, say, VT100/VT52).
|
||||||
|
// If cursor previous line (ESC[ + <n> + F) were better supported
|
||||||
|
// we could use that above to eliminate this step.
|
||||||
|
cursorBack(out, r.width)
|
||||||
|
clearLine(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.linesRendered = 0
|
||||||
|
lines := strings.Split(r.buf.String(), "\n")
|
||||||
|
|
||||||
|
// Paint new lines
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
if _, exists := r.ignoreLines[r.linesRendered]; exists {
|
||||||
|
cursorDown(out) // skip rendering for this line.
|
||||||
|
} else {
|
||||||
|
line := lines[i]
|
||||||
|
|
||||||
|
// Truncate lines wider than the width of the window to avoid
|
||||||
|
// rendering troubles. If we don't have the width of the window
|
||||||
|
// this will be ignored.
|
||||||
|
//
|
||||||
|
// Note that on Windows we can't get the width of the window
|
||||||
|
// (signal SIGWINCH is not supported), so this will be ignored.
|
||||||
|
if r.width > 0 {
|
||||||
|
line = truncate.String(line, uint(r.width))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = io.WriteString(out, line)
|
||||||
|
|
||||||
|
if i != len(lines)-1 {
|
||||||
|
_, _ = io.WriteString(out, "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.linesRendered++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the cursor is at the start of the last line to keep rendering
|
||||||
|
// behavior consistent.
|
||||||
|
if r.altScreenActive {
|
||||||
|
// We need this case to fix a bug in macOS terminal. In other terminals
|
||||||
|
// the below case seems to do the job regardless of whether or not we're
|
||||||
|
// using the full terminal window.
|
||||||
|
moveCursor(out, r.linesRendered, 0)
|
||||||
|
} else {
|
||||||
|
cursorBack(out, r.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = 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 *standardRenderer) write(s string) {
|
||||||
|
r.mtx.Lock()
|
||||||
|
defer r.mtx.Unlock()
|
||||||
|
r.buf.Reset()
|
||||||
|
_, _ = r.buf.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *standardRenderer) altScreen() bool {
|
||||||
|
return r.altScreenActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *standardRenderer) setAltScreen(v bool) {
|
||||||
|
r.altScreenActive = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
|
||||||
|
// renderer.
|
||||||
|
func (r *standardRenderer) setIgnoredLines(from int, to int) {
|
||||||
|
// Lock if we're going to be clearing some lines since we don't want
|
||||||
|
// anything jacking our cursor.
|
||||||
|
if r.linesRendered > 0 {
|
||||||
|
r.mtx.Lock()
|
||||||
|
defer r.mtx.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.ignoreLines == nil {
|
||||||
|
r.ignoreLines = make(map[int]struct{})
|
||||||
|
}
|
||||||
|
for i := from; i < to; i++ {
|
||||||
|
r.ignoreLines[i] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erase ignored lines
|
||||||
|
if r.linesRendered > 0 {
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
for i := r.linesRendered - 1; i >= 0; i-- {
|
||||||
|
if _, exists := r.ignoreLines[i]; exists {
|
||||||
|
clearLine(out)
|
||||||
|
}
|
||||||
|
cursorUp(out)
|
||||||
|
}
|
||||||
|
moveCursor(out, r.linesRendered, 0) // put cursor back
|
||||||
|
_, _ = r.out.Write(out.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearIgnoredLines returns control of any ignored lines to the standard
|
||||||
|
// Bubble Tea renderer. That is, any lines previously set to be ignored can be
|
||||||
|
// rendered to again.
|
||||||
|
func (r *standardRenderer) clearIgnoredLines() {
|
||||||
|
r.ignoreLines = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// To call this function use command ScrollUp().
|
||||||
|
//
|
||||||
|
// 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 philosophically
|
||||||
|
// 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 *standardRenderer) insertTop(lines []string, topBoundary, bottomBoundary int) {
|
||||||
|
r.mtx.Lock()
|
||||||
|
defer r.mtx.Unlock()
|
||||||
|
|
||||||
|
b := new(bytes.Buffer)
|
||||||
|
|
||||||
|
changeScrollingRegion(b, topBoundary, bottomBoundary)
|
||||||
|
moveCursor(b, topBoundary, 0)
|
||||||
|
insertLine(b, len(lines))
|
||||||
|
_, _ = io.WriteString(b, strings.Join(lines, "\r\n"))
|
||||||
|
changeScrollingRegion(b, 0, r.height)
|
||||||
|
|
||||||
|
// Move cursor back to where the main rendering routine expects it to be
|
||||||
|
moveCursor(b, r.linesRendered, 0)
|
||||||
|
|
||||||
|
_, _ = 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.
|
||||||
|
//
|
||||||
|
// To call this function use the command ScrollDown().
|
||||||
|
//
|
||||||
|
// See note in insertTop() for caveats, how this function only makes sense for
|
||||||
|
// full-window applications, and how it differs from the normal way we do
|
||||||
|
// rendering in Bubble Tea.
|
||||||
|
func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBoundary int) {
|
||||||
|
r.mtx.Lock()
|
||||||
|
defer r.mtx.Unlock()
|
||||||
|
|
||||||
|
b := new(bytes.Buffer)
|
||||||
|
|
||||||
|
changeScrollingRegion(b, topBoundary, bottomBoundary)
|
||||||
|
moveCursor(b, bottomBoundary, 0)
|
||||||
|
_, _ = io.WriteString(b, "\r\n"+strings.Join(lines, "\r\n"))
|
||||||
|
changeScrollingRegion(b, 0, r.height)
|
||||||
|
|
||||||
|
// Move cursor back to where the main rendering routine expects it to be
|
||||||
|
moveCursor(b, r.linesRendered, 0)
|
||||||
|
|
||||||
|
_, _ = r.out.Write(b.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessages handles internal messages for the renderer.
|
||||||
|
func (r *standardRenderer) handleMessages(msg Msg) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case WindowSizeMsg:
|
||||||
|
r.width = msg.Width
|
||||||
|
r.height = msg.Height
|
||||||
|
|
||||||
|
case clearScrollAreaMsg:
|
||||||
|
r.clearIgnoredLines()
|
||||||
|
|
||||||
|
// Force a repaint on the area where the scrollable stuff was in this
|
||||||
|
// update cycle
|
||||||
|
r.mtx.Lock()
|
||||||
|
r.lastRender = ""
|
||||||
|
r.mtx.Unlock()
|
||||||
|
|
||||||
|
case syncScrollAreaMsg:
|
||||||
|
// Re-render scrolling area
|
||||||
|
r.clearIgnoredLines()
|
||||||
|
r.setIgnoredLines(msg.topBoundary, msg.bottomBoundary)
|
||||||
|
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
||||||
|
|
||||||
|
// Force non-scrolling stuff to repaint in this update cycle
|
||||||
|
r.mtx.Lock()
|
||||||
|
r.lastRender = ""
|
||||||
|
r.mtx.Unlock()
|
||||||
|
|
||||||
|
case scrollUpMsg:
|
||||||
|
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
||||||
|
|
||||||
|
case scrollDownMsg:
|
||||||
|
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HIGH-PERFORMANCE RENDERING STUFF
|
||||||
|
|
||||||
|
type syncScrollAreaMsg struct {
|
||||||
|
lines []string
|
||||||
|
topBoundary int
|
||||||
|
bottomBoundary int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncScrollArea performs a paint of the entire region designated to be the
|
||||||
|
// scrollable area. This is required to initialize the scrollable region and
|
||||||
|
// should also be called on resize (WindowSizeMsg).
|
||||||
|
//
|
||||||
|
// For high-performance, scroll-based rendering only.
|
||||||
|
func SyncScrollArea(lines []string, topBoundary int, bottomBoundary int) Cmd {
|
||||||
|
return func() Msg {
|
||||||
|
return syncScrollAreaMsg{
|
||||||
|
lines: lines,
|
||||||
|
topBoundary: topBoundary,
|
||||||
|
bottomBoundary: bottomBoundary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type clearScrollAreaMsg struct{}
|
||||||
|
|
||||||
|
// ClearScrollArea deallocates the scrollable region and returns the control of
|
||||||
|
// those lines to the main rendering routine.
|
||||||
|
//
|
||||||
|
// For high-performance, scroll-based rendering only.
|
||||||
|
func ClearScrollArea() Msg {
|
||||||
|
return clearScrollAreaMsg{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scrollUpMsg struct {
|
||||||
|
lines []string
|
||||||
|
topBoundary int
|
||||||
|
bottomBoundary int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollUp adds lines to the top of the scrollable region, pushing existing
|
||||||
|
// lines below down. Lines that are pushed out the scrollable region disappear
|
||||||
|
// from view.
|
||||||
|
//
|
||||||
|
// For high-performance, scroll-based rendering only.
|
||||||
|
func ScrollUp(newLines []string, topBoundary, bottomBoundary int) Cmd {
|
||||||
|
return func() Msg {
|
||||||
|
return scrollUpMsg{
|
||||||
|
lines: newLines,
|
||||||
|
topBoundary: topBoundary,
|
||||||
|
bottomBoundary: bottomBoundary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scrollDownMsg struct {
|
||||||
|
lines []string
|
||||||
|
topBoundary int
|
||||||
|
bottomBoundary int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollDown adds lines to the bottom of the scrollable region, pushing
|
||||||
|
// existing lines above up. Lines that are pushed out of the scrollable region
|
||||||
|
// disappear from view.
|
||||||
|
//
|
||||||
|
// For high-performance, scroll-based rendering only.
|
||||||
|
func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
|
||||||
|
return func() Msg {
|
||||||
|
return scrollDownMsg{
|
||||||
|
lines: newLines,
|
||||||
|
topBoundary: topBoundary,
|
||||||
|
bottomBoundary: bottomBoundary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
tea.go
30
tea.go
|
@ -98,6 +98,20 @@ func WithoutCatchPanics() ProgramOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithoutRenderer disables the renderer. When this is set output and log
|
||||||
|
// statements will be plainly sent to stdout (or another output if one is set)
|
||||||
|
// without any rendering and redrawing logic. In other words, printing and
|
||||||
|
// logging will behave the same way it would in a non-TUI commandline tool.
|
||||||
|
// This can be useful if you want to use the Bubble Tea framework for a non-TUI
|
||||||
|
// application, or to provide an additional non-TUI mode to your Bubble Tea
|
||||||
|
// programs. For example, your program could behave like a daemon if output is
|
||||||
|
// not a TTY.
|
||||||
|
func WithoutRenderer() ProgramOption {
|
||||||
|
return func(m *Program) {
|
||||||
|
m.renderer = &nilRenderer{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// inputStatus indicates the current state of the input. By default, input is
|
// inputStatus indicates the current state of the input. By default, input is
|
||||||
// stdin, however we'll change this if input's not a TTY. The user can also set
|
// stdin, however we'll change this if input's not a TTY. The user can also set
|
||||||
// the input.
|
// the input.
|
||||||
|
@ -125,7 +139,7 @@ type Program struct {
|
||||||
|
|
||||||
output io.Writer // where to send output. this will usually be os.Stdout.
|
output io.Writer // where to send output. this will usually be os.Stdout.
|
||||||
input io.Reader // this will usually be os.Stdin.
|
input io.Reader // this will usually be os.Stdin.
|
||||||
renderer *renderer
|
renderer renderer
|
||||||
altScreenActive bool
|
altScreenActive bool
|
||||||
|
|
||||||
// CatchPanics is incredibly useful for restoring the terminal to a usable
|
// CatchPanics is incredibly useful for restoring the terminal to a usable
|
||||||
|
@ -263,7 +277,10 @@ func (p *Program) Start() error {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no renderer is set use the standard one.
|
||||||
|
if p.renderer == nil {
|
||||||
p.renderer = newRenderer(p.output, p.mtx)
|
p.renderer = newRenderer(p.output, p.mtx)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if output is a TTY before entering raw mode, hiding the cursor and
|
// Check if output is a TTY before entering raw mode, hiding the cursor and
|
||||||
// so on.
|
// so on.
|
||||||
|
@ -286,7 +303,7 @@ func (p *Program) Start() error {
|
||||||
|
|
||||||
// Start renderer
|
// Start renderer
|
||||||
p.renderer.start()
|
p.renderer.start()
|
||||||
p.renderer.altScreenActive = p.altScreenActive
|
p.renderer.setAltScreen(p.altScreenActive)
|
||||||
|
|
||||||
// Render initial view
|
// Render initial view
|
||||||
p.renderer.write(model.View())
|
p.renderer.write(model.View())
|
||||||
|
@ -365,7 +382,10 @@ func (p *Program) Start() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process internal messages for the renderer
|
// Process internal messages for the renderer
|
||||||
p.renderer.handleMessages(msg)
|
if r, ok := p.renderer.(*standardRenderer); ok {
|
||||||
|
r.handleMessages(msg)
|
||||||
|
}
|
||||||
|
|
||||||
var cmd Cmd
|
var cmd Cmd
|
||||||
model, cmd = model.Update(msg) // run update
|
model, cmd = model.Update(msg) // run update
|
||||||
cmds <- cmd // process command (if any)
|
cmds <- cmd // process command (if any)
|
||||||
|
@ -384,7 +404,7 @@ func (p *Program) EnterAltScreen() {
|
||||||
|
|
||||||
p.altScreenActive = true
|
p.altScreenActive = true
|
||||||
if p.renderer != nil {
|
if p.renderer != nil {
|
||||||
p.renderer.altScreenActive = p.altScreenActive
|
p.renderer.setAltScreen(p.altScreenActive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -396,7 +416,7 @@ func (p *Program) ExitAltScreen() {
|
||||||
|
|
||||||
p.altScreenActive = false
|
p.altScreenActive = false
|
||||||
if p.renderer != nil {
|
if p.renderer != nil {
|
||||||
p.renderer.altScreenActive = p.altScreenActive
|
p.renderer.setAltScreen(p.altScreenActive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue