bubbletea/tea.go

589 lines
14 KiB
Go
Raw Normal View History

2020-07-30 11:29:20 -04:00
// Package tea provides a framework for building rich terminal user interfaces
// based on the paradigms of The Elm Architecture. It's well-suited for simple
// and complex terminal applications, either inline, full-window, or a mix of
// both. It's been battle-tested in several large projects and is
// production-ready.
2020-07-29 20:49:20 -04:00
//
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials
//
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples
package tea
2020-01-10 16:02:04 -05:00
import (
2021-01-11 16:33:14 -05:00
"context"
"fmt"
"io"
"os"
2021-01-11 16:33:14 -05:00
"os/signal"
"runtime/debug"
2021-01-11 16:33:14 -05:00
"syscall"
"time"
2020-01-31 07:47:36 -05:00
"github.com/containerd/console"
isatty "github.com/mattn/go-isatty"
"github.com/muesli/cancelreader"
"github.com/muesli/termenv"
"golang.org/x/term"
2020-01-10 16:02:04 -05:00
)
2021-09-28 13:54:25 -04:00
// Msg contain data from the result of a IO operation. Msgs trigger the update
// function and, henceforth, the UI.
2020-01-10 16:02:04 -05:00
type Msg interface{}
2021-09-28 13:54:25 -04:00
// Model contains the program's state as well as its core functions.
type Model interface {
// Init is the first function that will be called. It returns an optional
// initial command. To not perform an initial command return nil.
Init() Cmd
// Update is called when a message is received. Use it to inspect messages
// and, in response, update the model and/or send a command.
Update(Msg) (Model, Cmd)
// View renders the program's UI, which is just a string. The view is
// rendered after every Update.
View() string
}
2020-01-10 16:02:04 -05:00
2021-09-28 13:54:25 -04:00
// Cmd is an IO operation that returns a message when it's complete. If it's
// nil it's considered a no-op. Use it for things like HTTP requests, timers,
// saving and loading from disk, and so on.
2020-06-24 12:14:35 -04:00
//
2021-09-28 13:54:25 -04:00
// Note that there's almost never a reason to use a command to send a message
// to another part of your program. That can almost always be done in the
// update function.
type Cmd func() Msg
// Options to customize the program during its initialization. These are
// generally set with ProgramOptions.
//
// The options here are treated as bits.
type startupOptions byte
2021-07-29 15:43:03 -04:00
func (s startupOptions) has(option startupOptions) bool {
return s&option != 0
}
const (
withAltScreen startupOptions = 1 << iota
withMouseCellMotion
withMouseAllMotion
2021-07-29 15:43:03 -04:00
withInputTTY
withCustomInput
withANSICompressor
withoutSignalHandler
)
// Program is a terminal user interface.
2020-01-10 16:02:04 -05:00
type Program struct {
initialModel Model
// Configuration options that will set as the program is initializing,
// treated as bits. These options can be set via various ProgramOptions.
startupOptions startupOptions
ctx context.Context
msgs chan Msg
errs chan error
readLoopDone chan struct{}
output *termenv.Output // where to send output. this will usually be os.Stdout.
restoreOutput func() error
input io.Reader // this will usually be os.Stdin.
cancelReader cancelreader.CancelReader
renderer renderer
altScreenWasActive bool // was the altscreen active before releasing the terminal?
// CatchPanics is incredibly useful for restoring the terminal to a usable
// state after a panic occurs. When this is set, Bubble Tea will recover
// from panics, print the stack trace, and disable raw mode. This feature
// is on by default.
CatchPanics bool
2021-01-11 16:33:14 -05:00
ignoreSignals bool
killc chan bool
console console.Console
// Stores the original reference to stdin for cases where input is not a
// TTY on windows and we've automatically opened CONIN$ to receive input.
// When the program exits this will be restored.
//
// Lint ignore note: the linter will find false positive on unix systems
// as this value only comes into play on Windows, hence the ignore comment
// below.
windowsStdin *os.File //nolint:golint,structcheck,unused
2020-01-10 16:02:04 -05:00
}
2020-07-29 20:49:20 -04:00
// Quit is a special command that tells the Bubble Tea program to exit.
func Quit() Msg {
2020-01-10 16:02:04 -05:00
return quitMsg{}
}
// quitMsg in an internal message signals that the program should quit. You can
// send a quitMsg with Quit.
2020-01-10 16:02:04 -05:00
type quitMsg struct{}
// NewProgram creates a new Program.
func NewProgram(model Model, opts ...ProgramOption) *Program {
p := &Program{
initialModel: model,
input: os.Stdin,
msgs: make(chan Msg),
CatchPanics: true,
killc: make(chan bool, 1),
2020-01-10 16:02:04 -05:00
}
2021-09-28 13:54:25 -04:00
// Apply all options to the program.
for _, opt := range opts {
opt(p)
}
// if no output was set, set it to stdout
if p.output == nil {
p.output = termenv.DefaultOutput()
// cache detected color values
termenv.WithColorCache(true)(p.output)
}
p.restoreOutput, _ = termenv.EnableVirtualTerminalProcessing(p.output)
return p
2020-01-10 16:02:04 -05:00
}
func (p *Program) handleSignals() chan struct{} {
ch := make(chan struct{})
// Listen for SIGINT and SIGTERM.
//
// In most cases ^C will not send an interrupt because the terminal will be
// in raw mode and ^C will be captured as a keystroke and sent along to
// Program.Update as a KeyMsg. When input is not a TTY, however, ^C will be
// caught here.
//
// SIGTERM is sent by unix utilities (like kill) to terminate a process.
2021-01-11 16:33:14 -05:00
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
defer func() {
signal.Stop(sig)
close(ch)
}()
2021-01-11 16:33:14 -05:00
for {
select {
case <-p.ctx.Done():
return
case <-sig:
if !p.ignoreSignals {
p.msgs <- quitMsg{}
return
}
}
2021-01-11 16:33:14 -05:00
}
}()
return ch
}
// handleResize handles terminal resize events.
func (p *Program) handleResize() chan struct{} {
ch := make(chan struct{})
2020-01-10 16:02:04 -05:00
if f, ok := p.output.TTY().(*os.File); ok && isatty.IsTerminal(f.Fd()) {
2021-09-28 13:54:25 -04:00
// Get the initial terminal size and send it to the program.
go func() {
w, h, err := term.GetSize(int(f.Fd()))
if err != nil {
p.errs <- err
}
select {
case <-p.ctx.Done():
case p.msgs <- WindowSizeMsg{w, h}:
}
}()
2021-09-28 13:54:25 -04:00
// Listen for window resizes.
go listenForResize(p.ctx, f, p.msgs, p.errs, ch)
} else {
close(ch)
}
return ch
}
// handleCommands runs commands in a goroutine and sends the result to the
// program's message channel.
func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
ch := make(chan struct{})
2020-01-10 16:02:04 -05:00
go func() {
defer close(ch)
2020-01-10 16:02:04 -05:00
for {
select {
case <-p.ctx.Done():
2020-01-10 16:02:04 -05:00
return
2020-01-10 16:02:04 -05:00
case cmd := <-cmds:
if cmd == nil {
continue
2020-01-10 16:02:04 -05:00
}
// Don't wait on these goroutines, otherwise the shutdown
// latency would get too large as a Cmd can run for some time
// (e.g. tick commands that sleep for half a second). It's not
// possible to cancel them so we'll have to leak the goroutine
// until Cmd returns.
go func() {
select {
case p.msgs <- cmd():
case <-p.ctx.Done():
}
}()
2020-01-10 16:02:04 -05:00
}
}
}()
return ch
}
// eventLoop is the central message loop. It receives and handles the default
// Bubble Tea messages, update the model and triggers redraws.
func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
2020-01-10 16:02:04 -05:00
for {
select {
case <-p.killc:
return nil, nil
case err := <-p.errs:
return model, err
case msg := <-p.msgs:
2021-09-28 13:54:25 -04:00
// Handle special internal messages.
switch msg := msg.(type) {
2020-12-03 10:44:10 -05:00
case quitMsg:
return model, nil
2022-08-25 13:13:30 -04:00
case clearScreenMsg:
p.renderer.clearScreen()
case enterAltScreenMsg:
2022-10-03 18:04:05 -04:00
p.renderer.enterAltScreen()
case exitAltScreenMsg:
2022-10-03 18:04:05 -04:00
p.renderer.exitAltScreen()
case enableMouseCellMotionMsg:
2022-10-03 18:04:05 -04:00
p.renderer.enableMouseCellMotion()
case enableMouseAllMotionMsg:
2022-10-03 18:04:05 -04:00
p.renderer.enableMouseAllMotion()
case disableMouseMsg:
2022-10-03 18:04:05 -04:00
p.renderer.disableMouseCellMotion()
p.renderer.disableMouseAllMotion()
case showCursorMsg:
p.renderer.showCursor()
2020-12-03 10:44:10 -05:00
case hideCursorMsg:
2022-10-03 18:04:05 -04:00
p.renderer.hideCursor()
case execMsg:
// NB: this blocks.
p.exec(msg.cmd, msg.fn)
2022-10-03 18:04:05 -04:00
case batchMsg:
for _, cmd := range msg {
cmds <- cmd
}
continue
case sequenceMsg:
go func() {
// Execute commands one at a time, in order.
for _, cmd := range msg {
select {
case p.msgs <- cmd():
case <-p.ctx.Done():
}
}
}()
2020-01-10 16:02:04 -05:00
}
2021-09-28 13:54:25 -04:00
// Process internal messages for the renderer.
if r, ok := p.renderer.(*standardRenderer); ok {
r.handleMessages(msg)
}
2020-06-23 16:11:06 -04:00
var cmd Cmd
model, cmd = model.Update(msg) // run update
cmds <- cmd // process command (if any)
p.renderer.write(model.View()) // send view to renderer
2020-01-10 16:02:04 -05:00
}
}
}
// StartReturningModel initializes the program. Returns the final model.
func (p *Program) StartReturningModel() (Model, error) {
cmds := make(chan Cmd)
p.errs = make(chan error)
var cancelContext context.CancelFunc
p.ctx, cancelContext = context.WithCancel(context.Background())
defer cancelContext()
switch {
case p.startupOptions.has(withInputTTY):
// Open a new TTY, by request
f, err := openInputTTY()
if err != nil {
return p.initialModel, err
}
defer f.Close() //nolint:errcheck
p.input = f
case !p.startupOptions.has(withCustomInput):
// If the user hasn't set a custom input, and input's not a terminal,
// open a TTY so we can capture input as normal. This will allow things
// to "just work" in cases where data was piped or redirected into this
// application.
f, isFile := p.input.(*os.File)
if !isFile {
break
}
if isatty.IsTerminal(f.Fd()) {
break
}
f, err := openInputTTY()
if err != nil {
return p.initialModel, err
}
defer f.Close() //nolint:errcheck
p.input = f
}
// Handle signals.
sigintLoopDone := make(chan struct{})
if !p.startupOptions.has(withoutSignalHandler) {
sigintLoopDone = p.handleSignals()
} else {
close(sigintLoopDone)
}
if p.CatchPanics {
defer func() {
if r := recover(); r != nil {
p.shutdown(true)
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
return
}
}()
}
// If no renderer is set use the standard one.
if p.renderer == nil {
p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor))
}
// Check if output is a TTY before entering raw mode, hiding the cursor and
// so on.
if err := p.initTerminal(); err != nil {
return p.initialModel, err
}
// Honor program startup options.
if p.startupOptions&withAltScreen != 0 {
p.renderer.enterAltScreen()
}
if p.startupOptions&withMouseCellMotion != 0 {
p.renderer.enableMouseCellMotion()
} else if p.startupOptions&withMouseAllMotion != 0 {
p.renderer.enableMouseAllMotion()
}
// Initialize the program.
initSignalDone := make(chan struct{})
model := p.initialModel
if initCmd := model.Init(); initCmd != nil {
go func() {
defer close(initSignalDone)
select {
case cmds <- initCmd:
case <-p.ctx.Done():
}
}()
} else {
close(initSignalDone)
}
// Start the renderer.
p.renderer.start()
// Render the initial view.
p.renderer.write(model.View())
// Subscribe to user input.
if p.input != nil {
if err := p.initCancelReader(); err != nil {
return model, err
}
} else {
defer close(p.readLoopDone)
}
defer p.cancelReader.Close() //nolint:errcheck
// Handle resize events.
resizeLoopDone := p.handleResize()
// Process commands.
cmdLoopDone := p.handleCommands(cmds)
// Run event loop, handle updates and draw.
model, err := p.eventLoop(model, cmds)
// Tear down.
cancelContext()
// Wait for input loop to finish.
if p.cancelReader.Cancel() {
p.waitForReadLoop()
}
<-cmdLoopDone
<-resizeLoopDone
<-sigintLoopDone
<-initSignalDone
p.shutdown(false)
return model, err
}
// Start initializes the program. Ignores the final model.
func (p *Program) Start() error {
_, err := p.StartReturningModel()
return err
}
// Send sends a message to the main update function, effectively allowing
// messages to be injected from outside the program for interoperability
// purposes.
//
// If the program is not running this this will be a no-op, so it's safe to
// send messages if the program is unstarted, or has exited.
func (p *Program) Send(msg Msg) {
p.msgs <- msg
}
// Quit is a convenience function for quitting Bubble Tea programs. Use it
// when you need to shut down a Bubble Tea program from the outside.
//
// If you wish to quit from within a Bubble Tea program use the Quit command.
//
// If the program is not running this will be a no-op, so it's safe to call
// if the program is unstarted or has already exited.
func (p *Program) Quit() {
p.Send(Quit())
}
// Kill stops the program immediately and restores the former terminal state.
// The final render that you would normally see when quitting will be skipped.
func (p *Program) Kill() {
p.killc <- true
p.shutdown(true)
}
// shutdown performs operations to free up resources and restore the terminal
// to its original state.
func (p *Program) shutdown(kill bool) {
if p.renderer != nil {
if kill {
p.renderer.kill()
} else {
p.renderer.stop()
}
}
p.ExitAltScreen()
p.DisableMouseCellMotion()
p.DisableMouseAllMotion()
_ = p.restoreTerminalState()
if p.restoreOutput != nil {
_ = p.restoreOutput()
}
}
// ReleaseTerminal restores the original terminal state and cancels the input
// reader. You can return control to the Program with RestoreTerminal.
func (p *Program) ReleaseTerminal() error {
p.ignoreSignals = true
p.cancelInput()
p.waitForReadLoop()
2022-10-03 18:04:05 -04:00
p.altScreenWasActive = p.renderer.altScreen()
if p.renderer.altScreen() {
p.ExitAltScreen()
time.Sleep(time.Millisecond * 10) // give the terminal a moment to catch up
}
return p.restoreTerminalState()
}
// RestoreTerminal reinitializes the Program's input reader, restores the
// terminal to the former state when the program was running, and repaints.
// Use it to reinitialize a Program after running ReleaseTerminal.
func (p *Program) RestoreTerminal() error {
p.ignoreSignals = false
if err := p.initTerminal(); err != nil {
return err
}
if err := p.initCancelReader(); err != nil {
return err
}
if p.altScreenWasActive {
p.EnterAltScreen()
}
go p.Send(repaintMsg{})
return nil
}
2022-06-28 15:34:22 -04:00
// Println prints above the Program. This output is unmanaged by the program
// and will persist across renders by the Program.
//
// If the altscreen is active no output will be printed.
func (p *Program) Println(args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func (p *Program) Printf(template string, args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}