From b3f62af8b546ed7c928478cfe8a25dc7df801da3 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 9 Feb 2021 13:33:25 -0500 Subject: [PATCH] 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. --- examples/go.mod | 1 + examples/tui-daemon-combo/main.go | 121 +++++++++ nil_renderer.go | 9 + renderer.go | 393 +---------------------------- standard_renderer.go | 398 ++++++++++++++++++++++++++++++ tea.go | 32 ++- 6 files changed, 561 insertions(+), 393 deletions(-) create mode 100644 examples/tui-daemon-combo/main.go create mode 100644 nil_renderer.go create mode 100644 standard_renderer.go diff --git a/examples/go.mod b/examples/go.mod index f739867..89b71a0 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/glamour v0.2.0 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 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/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 github.com/muesli/termenv v0.7.4 diff --git a/examples/tui-daemon-combo/main.go b/examples/tui-daemon-combo/main.go new file mode 100644 index 0000000..3a81108 --- /dev/null +++ b/examples/tui-daemon-combo/main.go @@ -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) +} diff --git a/nil_renderer.go b/nil_renderer.go new file mode 100644 index 0000000..b8fce9c --- /dev/null +++ b/nil_renderer.go @@ -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) {} diff --git a/renderer.go b/renderer.go index f422670..a795b9c 100644 --- a/renderer.go +++ b/renderer.go @@ -1,390 +1,9 @@ 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 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[ + + 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, - } - } +type renderer interface { + start() + stop() + write(string) + altScreen() bool + setAltScreen(bool) } diff --git a/standard_renderer.go b/standard_renderer.go new file mode 100644 index 0000000..5fb48e8 --- /dev/null +++ b/standard_renderer.go @@ -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[ + + 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, + } + } +} diff --git a/tea.go b/tea.go index 7da8e09..cd82ce2 100644 --- a/tea.go +++ b/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 // stdin, however we'll change this if input's not a TTY. The user can also set // the input. @@ -125,7 +139,7 @@ type Program struct { output io.Writer // where to send output. this will usually be os.Stdout. input io.Reader // this will usually be os.Stdin. - renderer *renderer + renderer renderer altScreenActive bool // CatchPanics is incredibly useful for restoring the terminal to a usable @@ -263,7 +277,10 @@ func (p *Program) Start() error { }() } - p.renderer = newRenderer(p.output, p.mtx) + // If no renderer is set use the standard one. + if p.renderer == nil { + p.renderer = newRenderer(p.output, p.mtx) + } // Check if output is a TTY before entering raw mode, hiding the cursor and // so on. @@ -286,7 +303,7 @@ func (p *Program) Start() error { // Start renderer p.renderer.start() - p.renderer.altScreenActive = p.altScreenActive + p.renderer.setAltScreen(p.altScreenActive) // Render initial view p.renderer.write(model.View()) @@ -365,7 +382,10 @@ func (p *Program) Start() error { } // Process internal messages for the renderer - p.renderer.handleMessages(msg) + if r, ok := p.renderer.(*standardRenderer); ok { + r.handleMessages(msg) + } + var cmd Cmd model, cmd = model.Update(msg) // run update cmds <- cmd // process command (if any) @@ -384,7 +404,7 @@ func (p *Program) EnterAltScreen() { p.altScreenActive = true 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 if p.renderer != nil { - p.renderer.altScreenActive = p.altScreenActive + p.renderer.setAltScreen(p.altScreenActive) } }