Repository: charmbracelet/bubbletea
Pull request #289 (2022-04-11 20:38:35)

fix(keys): spacebar now sends a `KeySpace`
This fixes a bug where the spacebar would send a `Key` with type `KeyRunes` instead of `KeySpace`. The `Runes` portion of `Key` will still contain a space after this fix.

```go
// This is what is sent when you press spacebar now
type Key struct {
    Type: KeySpace,
    Runes: []rune{' '},
}
```

* * *

## Changes:

- [x] remove keySP
- [x] assign KeySpace to " "
- [x] update tests to expect " " instead of "space"

I didn't make any changes to `readInput`. 
Manually tested `KeySpace` in addition to the tests with no issues and `KeySpace` worked as expected


Patch:
diff --git a/examples/exec/main.go b/examples/exec/main.go
new file mode 100644
index 0000000..3406cd0
--- /dev/null
+++ b/examples/exec/main.go
@@ -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)
+	}
+}
diff --git a/key.go b/key.go
index a0ceea6..c5b04e9 100644
--- a/key.go
+++ b/key.go
@@ -137,7 +137,6 @@ const (
 	keyGS  KeyType = 29  // group separator
 	keyRS  KeyType = 30  // record separator
 	keyUS  KeyType = 31  // unit separator
-	keySP  KeyType = 32  // space
 	keyDEL KeyType = 127 // delete. on most systems this is mapped to backspace, I hear
 )
 
@@ -148,7 +147,6 @@ const (
 	KeyEnter     KeyType = keyCR
 	KeyBackspace KeyType = keyDEL
 	KeyTab       KeyType = keyHT
-	KeySpace     KeyType = keySP
 	KeyEsc       KeyType = keyESC
 	KeyEscape    KeyType = keyESC
 
@@ -200,6 +198,7 @@ const (
 	KeyPgUp
 	KeyPgDown
 	KeyDelete
+	KeySpace
 	KeyF1
 	KeyF2
 	KeyF3
@@ -222,8 +221,9 @@ const (
 	KeyF20
 )
 
-// Mapping for control keys to friendly consts.
+// Mappings for control keys and other special keys to friendly consts.
 var keyNames = map[KeyType]string{
+	// Control keys.
 	keyNUL: "ctrl+@", // also ctrl+`
 	keySOH: "ctrl+a",
 	keySTX: "ctrl+b",
@@ -256,13 +256,14 @@ var keyNames = map[KeyType]string{
 	keyGS:  "ctrl+]",
 	keyRS:  "ctrl+^",
 	keyUS:  "ctrl+_",
-	keySP:  "space",
 	keyDEL: "backspace",
 
+	// Other keys.
 	KeyRunes:    "runes",
 	KeyUp:       "up",
 	KeyDown:     "down",
 	KeyRight:    "right",
+	KeySpace:    " ", // for backwards compatibility
 	KeyLeft:     "left",
 	KeyShiftTab: "shift+tab",
 	KeyHome:     "home",
@@ -480,7 +481,15 @@ func readInputs(input io.Reader) ([]Msg, error) {
 		}, nil
 	}
 
-	// Welp, it's just a regular, ol' single rune
+	// If it's a space, override the type with KeySpace (but still include the
+	// rune).
+	if len(runes) == 1 && runes[0] == ' ' {
+		return []Msg{
+			KeyMsg(Key{Type: KeySpace, Runes: runes}),
+		}, nil
+	}
+
+	// Welp, it's just a regular, ol' single rune.
 	return []Msg{
 		KeyMsg(Key{Type: KeyRunes, Runes: runes}),
 	}, nil
diff --git a/renderer.go b/renderer.go
index 5cb0163..aedbb66 100644
--- a/renderer.go
+++ b/renderer.go
@@ -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{}
diff --git a/screen.go b/screen.go
index d27cbf0..63db7ae 100644
--- a/screen.go
+++ b/screen.go
@@ -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)
+}
diff --git a/standard_renderer.go b/standard_renderer.go
index 33d14a7..12087bb 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -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
diff --git a/tea.go b/tea.go
index fab77d8..dd744a4 100644
--- a/tea.go
+++ b/tea.go
@@ -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))
+	}
+}
diff --git a/tty.go b/tty.go
index 0aadf9e..17e508b 100644
--- a/tty.go
+++ b/tty.go
@@ -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()
+}


Test patch:
diff --git a/key_test.go b/key_test.go
index 931fa84..511ae75 100644
--- a/key_test.go
+++ b/key_test.go
@@ -8,10 +8,10 @@ import (
 func TestKeyString(t *testing.T) {
 	t.Run("alt+space", func(t *testing.T) {
 		if got := KeyMsg(Key{
-			Type: keySP,
+			Type: KeySpace,
 			Alt:  true,
-		}).String(); got != "alt+space" {
-			t.Fatalf(`expected a "alt+space", got %q`, got)
+		}).String(); got != "alt+ " {
+			t.Fatalf(`expected a "alt+ ", got %q`, got)
 		}
 	})
 
@@ -35,8 +35,8 @@ func TestKeyString(t *testing.T) {
 
 func TestKeyTypeString(t *testing.T) {
 	t.Run("space", func(t *testing.T) {
-		if got := keySP.String(); got != "space" {
-			t.Fatalf(`expected a "space", got %q`, got)
+		if got := KeySpace.String(); got != " " {
+			t.Fatalf(`expected a " ", got %q`, got)
 		}
 	})