From 33be89cd2495e85063ba574b20f14801e3bf203e Mon Sep 17 00:00:00 2001 From: Christian Muehlhaeuser Date: Mon, 21 Feb 2022 07:15:00 +0100 Subject: [PATCH] add: program.ReleaseTerminal and RestoreTerminal to re-use input & terminal ReleaseTerminal makes BubbleTea release the input / terminal, so users can spawn a sub-command. RestoreTerminal sets the input reader up again and triggers a repaint. --- examples/exec/main.go | 65 +++++++++++++++++++++++++++++ tea.go | 96 ++++++++++++++++++++----------------------- tty.go | 45 ++++++++++++++++++++ 3 files changed, 155 insertions(+), 51 deletions(-) create mode 100644 examples/exec/main.go diff --git a/examples/exec/main.go b/examples/exec/main.go new file mode 100644 index 0000000000..d80cd95ba3 --- /dev/null +++ b/examples/exec/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + + tea "github.com/charmbracelet/bubbletea" +) + +var ( + p *tea.Program +) + +type model struct { + 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 "e": + if err := p.ReleaseTerminal(); err != nil { + m.err = err + return m, nil + } + + c := exec.Command(os.Getenv("EDITOR")) //nolint:gosec + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + m.err = c.Run() + + if err := p.RestoreTerminal(); err != nil { + m.err = err + } + + return m, nil + case "ctrl+c", "q": + return m, tea.Quit + } + } + return m, nil +} + +func (m model) View() string { + if m.err != nil { + return "Error: " + m.err.Error() + } + return "Press e to open Vim. Press q to quit." +} + +func main() { + m := model{} + p = tea.NewProgram(m) + if err := p.Start(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/tea.go b/tea.go index f5af6ad060..4fdd341ca6 100644 --- a/tea.go +++ b/tea.go @@ -11,7 +11,6 @@ package tea import ( "context" - "errors" "fmt" "io" "os" @@ -82,12 +81,17 @@ 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. + cancelReader cancelReader - 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 @@ -263,14 +267,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{}) @@ -279,7 +280,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 @@ -293,7 +294,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,7 +347,7 @@ func (p *Program) StartReturningModel() (Model, error) { }() select { - case <-ctx.Done(): + case <-p.ctx.Done(): case <-sig: p.msgs <- quitMsg{} } @@ -390,7 +392,7 @@ func (p *Program) StartReturningModel() (Model, error) { defer close(initSignalDone) select { case cmds <- initCmd: - case <-ctx.Done(): + case <-p.ctx.Done(): } }() } else { @@ -404,57 +406,32 @@ func (p *Program) StartReturningModel() (Model, error) { // Render the initial view. p.renderer.write(model.View()) - cancelReader, err := newCancelReader(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, 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) } @@ -465,7 +442,7 @@ func (p *Program) StartReturningModel() (Model, error) { for { select { - case <-ctx.Done(): + case <-p.ctx.Done(): return case cmd := <-cmds: @@ -481,7 +458,7 @@ func (p *Program) StartReturningModel() (Model, error) { go func() { select { case p.msgs <- cmd(): - case <-ctx.Done(): + case <-p.ctx.Done(): } }() } @@ -493,9 +470,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 @@ -505,7 +482,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 @@ -690,3 +667,20 @@ func (p *Program) DisableMouseAllMotion() { defer p.mtx.Unlock() fmt.Fprintf(p.output, te.CSI+te.DisableMouseAllMotionSeq) } + +func (p *Program) ReleaseTerminal() error { + p.cancelInput() + return p.restoreTerminal() +} + +func (p *Program) RestoreTerminal() error { + if err := p.initTerminal(); err != nil { + return err + } + if err := p.initCancelReader(); err != nil { + return err + } + + p.renderer.repaint() + return nil +} diff --git a/tty.go b/tty.go index 0aadf9ec81..970956b737 100644 --- a/tty.go +++ b/tty.go @@ -1,5 +1,10 @@ package tea +import ( + "errors" + "io" +) + func (p *Program) initTerminal() error { err := p.initInput() if err != nil { @@ -29,3 +34,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 = newCancelReader(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, 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() +}