add: Exec, ReleaseTerminal and RestoreTerminal to re-use input and terminal (#237)

* add: program.ReleaseTerminal and RestoreTerminal to re-use input & terminal

* chore(examples): add altscreen toggling to exec demo

* chore: put low-level altscreen stuff alongside other screen funcs

* docs: edit GoDocs for ReleaseTerminal and RestoreTerminal

* feat(renderer): add internal Msg renderMsg to immediately repaint

* fix: repaint instantly on RestoreTerminal

* fix: restore the altscreen state when restoring the terminal

* feat: implement Cmd-based API for blocking *exec.Cmds

* feat: allow Exec to return custom messages

* feat: allow Exec to be run without a callback

* fix: separate parameters for exec.Command examples

* fix: error message would get printed over by prompt in exec example

* fix: ignore signals while child process is running

* feat: allow to execute other things besides exec.Commands (#280)

* feat: allow to execute other things besides exec.Commands.

* fix: lint issues

* fix: renames, examples

* fix: callback type should be exported

* docs(exce): tiny ExecCommand doc comment correction

* chore(exec): break out Cmd for clarity's sake in example

* fix(exec): give the terminal a moment to catch up if exiting altscreen

* docs(exec): tidy up doc comments

* chore(exec): disambiguate methods for restoring the terminal state vs input

Co-authored-by: Christian Rocha <christian@rocha.is>
Co-authored-by: Carlos A Becker <caarlos0@gmail.com>
This commit is contained in:
Christian Muehlhaeuser
2022-04-12 16:23:10 +02:00
committed by GitHub
parent ecba57e455
commit 3795c036c4
6 changed files with 335 additions and 62 deletions

67
examples/exec/main.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"fmt"
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
)
type editorFinishedMsg struct{ err error }
func openEditor() tea.Cmd {
c := exec.Command(os.Getenv("EDITOR")) //nolint:gosec
return tea.Exec(tea.WrapExecCommand(c), func(err error) tea.Msg {
return editorFinishedMsg{err}
})
}
type model struct {
altscreenActive bool
err error
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "a":
m.altscreenActive = !m.altscreenActive
cmd := tea.EnterAltScreen
if !m.altscreenActive {
cmd = tea.ExitAltScreen
}
return m, cmd
case "e":
return m, openEditor()
case "ctrl+c", "q":
return m, tea.Quit
}
case editorFinishedMsg:
if msg.err != nil {
m.err = msg.err
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
if m.err != nil {
return "Error: " + m.err.Error() + "\n"
}
return "Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n"
}
func main() {
m := model{}
if err := tea.NewProgram(m).Start(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}

View File

@@ -15,7 +15,10 @@ type renderer interface {
// output at its discretion.
write(string)
// Request a full re-render.
// Request a full re-render. Note that this will not trigger a render
// immediately. Rather, this method causes the next render to be a full
// repaint. Because of this, it's safe to call this method multiple times
// in succession.
repaint()
// Whether or not the alternate screen buffer is enabled.
@@ -25,3 +28,6 @@ type renderer interface {
// does not actually toggle the alternate screen buffer.
setAltScreen(bool)
}
// repaintMsg forces a full repaint.
type repaintMsg struct{}

View File

@@ -42,3 +42,12 @@ func changeScrollingRegion(w io.Writer, top, bottom int) {
func cursorBack(w io.Writer, n int) {
fmt.Fprintf(w, te.CSI+te.CursorBackSeq, n)
}
func enterAltScreen(w io.Writer) {
fmt.Fprintf(w, te.CSI+te.AltScreenSeq)
moveCursor(w, 0, 0)
}
func exitAltScreen(w io.Writer) {
fmt.Fprintf(w, te.CSI+te.ExitAltScreenSeq)
}

View File

@@ -345,6 +345,11 @@ func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBound
// handleMessages handles internal messages for the renderer.
func (r *standardRenderer) handleMessages(msg Msg) {
switch msg := msg.(type) {
case repaintMsg:
// Force a repaint by clearing the render cache as we slide into a
// render.
r.repaint()
case WindowSizeMsg:
r.mtx.Lock()
r.width = msg.Width

257
tea.go
View File

@@ -11,10 +11,10 @@ package tea
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"runtime/debug"
"sync"
@@ -83,14 +83,20 @@ type Program struct {
// treated as bits. These options can be set via various ProgramOptions.
startupOptions startupOptions
ctx context.Context
mtx *sync.Mutex
msgs chan Msg
msgs chan Msg
errs chan error
readLoopDone chan 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
altScreenActive bool
output io.Writer // where to send output. this will usually be os.Stdout.
input io.Reader // this will usually be os.Stdin.
cancelReader cancelreader.CancelReader
renderer renderer
altScreenActive bool
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
@@ -98,6 +104,8 @@ type Program struct {
// is on by default.
CatchPanics bool
ignoreSignals bool
killc chan bool
console console.Console
@@ -238,6 +246,44 @@ func HideCursor() Msg {
return hideCursorMsg{}
}
// Exec runs the given ExecCommand in a blocking fashion, effectively pausing
// the Program while the command is running. After the *exec.Cmd exists the
// Program resumes. It's useful for spawning other interactive applications
// such as editors and shells from within a Program.
//
// To produce the command, pass an *exec.Command and a function which returns
// a message containing the error which may have occurred when running the
// *exec.Command.
//
// type VimFinishedMsg struct { err error }
//
// c := exec.Command("vim", "file.txt")
//
// cmd := Exec(WrapExecCommand(c), func(err error) Msg {
// return VimFinishedMsg{err: error}
// })
//
// Or, if you don't care about errors you could simply:
//
// cmd := Exec(WrapExecCommand(exec.Command("vim", "file.txt")), nil)
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func Exec(c ExecCommand, fn ExecCallback) Cmd {
return func() Msg {
return execMsg{cmd: c, fn: fn}
}
}
// ExecCallback is used when executing an *exec.Command to return a message
// with an error, which may or may not be nil.
type ExecCallback func(error) Msg
// execMsg is used internally to run an ExecCommand sent with Exec.
type execMsg struct {
cmd ExecCommand
fn ExecCallback
}
// hideCursorMsg is an internal command used to hide the cursor. You can send
// this message with HideCursor.
type hideCursorMsg struct{}
@@ -264,14 +310,11 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
// StartReturningModel initializes the program. Returns the final model.
func (p *Program) StartReturningModel() (Model, error) {
var (
cmds = make(chan Cmd)
errs = make(chan error)
)
cmds := make(chan Cmd)
p.errs = make(chan error)
// Channels for managing goroutine lifecycles.
var (
readLoopDone = make(chan struct{})
sigintLoopDone = make(chan struct{})
cmdLoopDone = make(chan struct{})
resizeLoopDone = make(chan struct{})
@@ -280,7 +323,7 @@ func (p *Program) StartReturningModel() (Model, error) {
waitForGoroutines = func(withReadLoop bool) {
if withReadLoop {
select {
case <-readLoopDone:
case <-p.readLoopDone:
case <-time.After(500 * time.Millisecond):
// The read loop hangs, which means the input
// cancelReader's cancel function has returned true even
@@ -294,7 +337,8 @@ func (p *Program) StartReturningModel() (Model, error) {
}
)
ctx, cancelContext := context.WithCancel(context.Background())
var cancelContext context.CancelFunc
p.ctx, cancelContext = context.WithCancel(context.Background())
defer cancelContext()
switch {
@@ -345,10 +389,16 @@ func (p *Program) StartReturningModel() (Model, error) {
close(sigintLoopDone)
}()
select {
case <-ctx.Done():
case <-sig:
p.msgs <- quitMsg{}
for {
select {
case <-p.ctx.Done():
return
case <-sig:
if !p.ignoreSignals {
p.msgs <- quitMsg{}
return
}
}
}
}()
@@ -391,7 +441,7 @@ func (p *Program) StartReturningModel() (Model, error) {
defer close(initSignalDone)
select {
case cmds <- initCmd:
case <-ctx.Done():
case <-p.ctx.Done():
}
}()
} else {
@@ -405,57 +455,32 @@ func (p *Program) StartReturningModel() (Model, error) {
// Render the initial view.
p.renderer.write(model.View())
cancelReader, err := cancelreader.NewReader(p.input)
if err != nil {
return model, err
}
defer cancelReader.Close() // nolint:errcheck
// Subscribe to user input.
if p.input != nil {
go func() {
defer close(readLoopDone)
for {
if ctx.Err() != nil {
return
}
msgs, err := readInputs(cancelReader)
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
errs <- err
}
return
}
for _, msg := range msgs {
p.msgs <- msg
}
}
}()
if err := p.initCancelReader(); err != nil {
return model, err
}
} else {
defer close(readLoopDone)
defer close(p.readLoopDone)
}
defer p.cancelReader.Close() // nolint:errcheck
if f, ok := p.output.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
// Get the initial terminal size and send it to the program.
go func() {
w, h, err := term.GetSize(int(f.Fd()))
if err != nil {
errs <- err
p.errs <- err
}
select {
case <-ctx.Done():
case <-p.ctx.Done():
case p.msgs <- WindowSizeMsg{w, h}:
}
}()
// Listen for window resizes.
go listenForResize(ctx, f, p.msgs, errs, resizeLoopDone)
go listenForResize(p.ctx, f, p.msgs, p.errs, resizeLoopDone)
} else {
close(resizeLoopDone)
}
@@ -466,7 +491,7 @@ func (p *Program) StartReturningModel() (Model, error) {
for {
select {
case <-ctx.Done():
case <-p.ctx.Done():
return
case cmd := <-cmds:
@@ -482,7 +507,7 @@ func (p *Program) StartReturningModel() (Model, error) {
go func() {
select {
case p.msgs <- cmd():
case <-ctx.Done():
case <-p.ctx.Done():
}
}()
}
@@ -494,9 +519,9 @@ func (p *Program) StartReturningModel() (Model, error) {
select {
case <-p.killc:
return nil, nil
case err := <-errs:
case err := <-p.errs:
cancelContext()
waitForGoroutines(cancelReader.Cancel())
waitForGoroutines(p.cancelReader.Cancel())
p.shutdown(false)
return model, err
@@ -506,7 +531,7 @@ func (p *Program) StartReturningModel() (Model, error) {
switch msg := msg.(type) {
case quitMsg:
cancelContext()
waitForGoroutines(cancelReader.Cancel())
waitForGoroutines(p.cancelReader.Cancel())
p.shutdown(false)
return model, nil
@@ -537,6 +562,10 @@ func (p *Program) StartReturningModel() (Model, error) {
case hideCursorMsg:
hideCursor(p.output)
case execMsg:
// Note: this blocks.
p.exec(msg.cmd, msg.fn)
}
// Process internal messages for the renderer.
@@ -599,7 +628,7 @@ func (p *Program) shutdown(kill bool) {
p.ExitAltScreen()
p.DisableMouseCellMotion()
p.DisableMouseAllMotion()
_ = p.restoreTerminal()
_ = p.restoreTerminalState()
}
// EnterAltScreen enters the alternate screen buffer, which consumes the entire
@@ -614,8 +643,7 @@ func (p *Program) EnterAltScreen() {
return
}
fmt.Fprintf(p.output, te.CSI+te.AltScreenSeq)
moveCursor(p.output, 0, 0)
enterAltScreen(p.output)
p.altScreenActive = true
if p.renderer != nil {
@@ -634,7 +662,7 @@ func (p *Program) ExitAltScreen() {
return
}
fmt.Fprintf(p.output, te.CSI+te.ExitAltScreenSeq)
exitAltScreen(p.output)
p.altScreenActive = false
if p.renderer != nil {
@@ -682,3 +710,112 @@ func (p *Program) DisableMouseAllMotion() {
defer p.mtx.Unlock()
fmt.Fprintf(p.output, te.CSI+te.DisableMouseAllMotionSeq)
}
// 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.altScreenWasActive = p.altScreenActive
if p.altScreenActive {
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
}
// ExecCommand can be implemented to execute things in the current
// terminal using the Exec Cmd.
type ExecCommand interface {
Run() error
SetStdin(io.Reader)
SetStdout(io.Writer)
SetStderr(io.Writer)
}
// WrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
// interface.
func WrapExecCommand(c *exec.Cmd) ExecCommand {
return &osExecCommand{Cmd: c}
}
// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
// interface.
type osExecCommand struct{ *exec.Cmd }
// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
func (c *osExecCommand) SetStdin(r io.Reader) {
// If unset, have the command use the same input as the terminal.
if c.Stdin == nil {
c.Stdin = r
}
}
// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStdout(w io.Writer) {
// If unset, have the command use the same output as the terminal.
if c.Stdout == nil {
c.Stdout = w
}
}
// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStderr(w io.Writer) {
// If unset, use stderr for the command's stderr
if c.Stderr == nil {
c.Stderr = w
}
}
// exec runs an ExecCommand and delivers the results to the program as a Msg.
func (p *Program) exec(c ExecCommand, fn ExecCallback) {
if err := p.ReleaseTerminal(); err != nil {
// If we can't release input, abort.
if fn != nil {
go p.Send(fn(err))
}
return
}
c.SetStdin(p.input)
c.SetStdout(p.output)
c.SetStderr(os.Stderr)
// Execute system command.
if err := c.Run(); err != nil {
_ = p.RestoreTerminal() // also try to restore the terminal.
if fn != nil {
go p.Send(fn(err))
}
return
}
// Have the program re-capture input.
err := p.RestoreTerminal()
if fn != nil {
go p.Send(fn(err))
}
}

51
tty.go
View File

@@ -1,5 +1,12 @@
package tea
import (
"errors"
"io"
"github.com/muesli/cancelreader"
)
func (p *Program) initTerminal() error {
err := p.initInput()
if err != nil {
@@ -17,7 +24,9 @@ func (p *Program) initTerminal() error {
return nil
}
func (p Program) restoreTerminal() error {
// restoreTerminalState restores the terminal to the state prior to running the
// Bubble Tea program.
func (p Program) restoreTerminalState() error {
showCursor(p.output)
if p.console != nil {
@@ -29,3 +38,43 @@ func (p Program) restoreTerminal() error {
return p.restoreInput()
}
// initCancelReader (re)commences reading inputs.
func (p *Program) initCancelReader() error {
var err error
p.cancelReader, err = cancelreader.NewReader(p.input)
if err != nil {
return err
}
p.readLoopDone = make(chan struct{})
go func() {
defer close(p.readLoopDone)
for {
if p.ctx.Err() != nil {
return
}
msgs, err := readInputs(p.cancelReader)
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
p.errs <- err
}
return
}
for _, msg := range msgs {
p.msgs <- msg
}
}
}()
return nil
}
// cancelInput cancels the input reader.
func (p *Program) cancelInput() {
p.cancelReader.Cancel()
}