Repository: charmbracelet/bubbletea (go)

Issue:
Using tea.Printf before tea.Quit
it is quite difficult to use the `tea.Printf` and `tea.Println` functions right before `tea.Quit`.
When using it with `tea.Batch` and `tea.Quit` for example, there is a race between `tea.Printf` and `tea.Quit`.

Example:

```go
// in this example, we are dealing with a list of tasks,
// display the latest state then start the next task.

  case foo:
     m.current++
     // if there is still work to do
     return m, tea.Batch( // this works fine
       tea.Println(m.tasks[m.current-1].View()),
       m.tasks[m.current].Update(),
     )
     // ...
     // Last element of the list, print it and leave the program
     return m, tea.Batch( // this doesn't work, race between Println and Quit
       tea.Println(m.tasks[m.current-1].View()),
       tea.Quit,
     )
```

By design `tea.Batch` doesn't provide any ordering guarantees, however its use with `tea.Printf` and `tea.Quit` makes it difficult to display *all* messages before quitting the program.

(If I understand correctly, the tea.Print* [example](https://github.com/charmbracelet/bubbletea/blob/master/examples/package-manager/main.go) has the same issue, although it misses the first entry in the list (it's more visible if you deactivate the randomness of the packages function))

I tried with `tea.Sequentially` but it returns the first non-nil message, this ignores the `tea.Quit` message.

Ideally I would like to use `tea.Printf`, then once it's rendered, use `tea.Quit`. Example:

```go
  return m, tea.QuitAfter(tea.Printf("...."))

```

What is the proper way of using `tea.Printf` and then `tea.Quit`?


Pull request description:
feat: add `Sequence` command for running commands in order
Closes #413. Also note that this deprecates `Sequentially`.

Patch:
diff --git a/README.md b/README.md
index 4889573..e8e07ac 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,11 @@ The fun, functional and stateful way to build terminal apps. A Go framework
 based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
 complex terminal applications, either inline, full-window, or a mix of both.
 
-<p>
-    <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0" width="800" alt="Bubble Tea Example">
-</p>
+<picture>
+  <source media="(max-width: 800px)" srcset="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0">
+  <source media="(min-width: 800px)" width="800" srcset="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0">
+  <img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif?0" alt="Bubble Tea Example">
+</picture>
 
 Bubble Tea is in use in production and includes a number of features and
 performance optimizations we’ve added along the way. Among those is a standard
diff --git a/commands.go b/commands.go
index 9111638..dd3305e 100644
--- a/commands.go
+++ b/commands.go
@@ -119,6 +119,8 @@ func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
 //	}
 //
 //	cmd := Sequentially(saveStateCmd, Quit)
+//
+// Deprecated: use Sequence instead.
 func Sequentially(cmds ...Cmd) Cmd {
 	return func() Msg {
 		for _, cmd := range cmds {
diff --git a/examples/composable-views/main.go b/examples/composable-views/main.go
new file mode 100644
index 0000000..9a876c2
--- /dev/null
+++ b/examples/composable-views/main.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"time"
+
+	"github.com/charmbracelet/bubbles/spinner"
+	"github.com/charmbracelet/bubbles/timer"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+/*
+This example assumes an existing understanding of commands and messages. If you
+haven't already read our tutorials on the basics of Bubble Tea and working
+with commands, we recommend reading those first.
+
+Find them at:
+https://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands
+https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics
+*/
+
+// sessionState is used to track which model is focused
+type sessionState uint
+
+const (
+	defaultTime              = time.Minute
+	timerView   sessionState = iota
+	spinnerView
+)
+
+var (
+	// Available spinners
+	spinners = []spinner.Spinner{
+		spinner.Line,
+		spinner.Dot,
+		spinner.MiniDot,
+		spinner.Jump,
+		spinner.Pulse,
+		spinner.Points,
+		spinner.Globe,
+		spinner.Moon,
+		spinner.Monkey,
+	}
+	modelStyle = lipgloss.NewStyle().
+			Padding(1, 2).
+			BorderStyle(lipgloss.HiddenBorder())
+	focusedModelStyle = lipgloss.NewStyle().
+				Padding(1, 2).
+				BorderStyle(lipgloss.NormalBorder()).
+				BorderForeground(lipgloss.Color("69"))
+	spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69"))
+	helpStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
+)
+
+type mainModel struct {
+	state   sessionState
+	timer   timer.Model
+	spinner spinner.Model
+	index   int
+}
+
+func newModel(timeout time.Duration) mainModel {
+	m := mainModel{state: timerView}
+	m.timer = timer.New(timeout)
+	m.spinner = spinner.New()
+	return m
+}
+
+func (m mainModel) Init() tea.Cmd {
+	// start the timer and spinner on program start
+	return tea.Batch(m.timer.Init(), m.spinner.Tick)
+}
+
+func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "q":
+			return m, tea.Quit
+		case "tab":
+			if m.state == timerView {
+				m.state = spinnerView
+			} else {
+				m.state = timerView
+			}
+		case "n":
+			if m.state == timerView {
+				m.timer = timer.New(defaultTime)
+				cmds = append(cmds, m.timer.Init())
+			} else {
+				m.Next()
+				m.resetSpinner()
+				cmds = append(cmds, spinner.Tick)
+			}
+		}
+		switch m.state {
+		// update whichever model is focused
+		case spinnerView:
+			m.spinner, cmd = m.spinner.Update(msg)
+			cmds = append(cmds, cmd)
+		default:
+			m.timer, cmd = m.timer.Update(msg)
+			cmds = append(cmds, cmd)
+		}
+	case spinner.TickMsg:
+		m.spinner, cmd = m.spinner.Update(msg)
+		cmds = append(cmds, cmd)
+	case timer.TickMsg:
+		m.timer, cmd = m.timer.Update(msg)
+		cmds = append(cmds, cmd)
+	}
+	return m, tea.Batch(cmds...)
+}
+
+func (m mainModel) View() string {
+	var s string
+	model := m.currentFocusedModel()
+	if m.state == timerView {
+		s += lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render( m.spinner.View()))
+	} else {
+		s += lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View()))
+	}
+	s += helpStyle.Render(fmt.Sprintf("\ntab: change focused model • n: new %s • q: exit\n", model))
+	return s
+}
+
+func (m mainModel) currentFocusedModel() string {
+	if m.state == timerView {
+		return "timer"
+	}
+	return "spinner"
+}
+
+func (m *mainModel) Next() {
+	if m.index == len(spinners)-1 {
+		m.index = 0
+	} else {
+		m.index++
+	}
+}
+
+func (m *mainModel) resetSpinner() {
+	m.spinner = spinner.New()
+	m.spinner.Style = spinnerStyle
+	m.spinner.Spinner = spinners[m.index]
+}
+
+func main() {
+	p := tea.NewProgram(newModel(defaultTime))
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/examples/debounce/main.go b/examples/debounce/main.go
new file mode 100644
index 0000000..834f521
--- /dev/null
+++ b/examples/debounce/main.go
@@ -0,0 +1,66 @@
+package main
+
+// This example illustrates how to debounce commands.
+//
+// When the user presses a key we increment the "tag" value on the model and,
+// after a short delay, we include that tag value in the message produced
+// by the Tick command.
+//
+// In a subsequent Update, if the tag in the Msg matches current tag on the
+// model's state we know that the debouncing is complete and we can proceeed as
+// normal. If not, we simply ignore the inbound message.
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+const debounceDuration = time.Second
+
+type exitMsg int
+
+type model struct {
+	tag int
+}
+
+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:
+		// Increment the tag on the model...
+		m.tag++
+		return m, tea.Tick(debounceDuration, func(_ time.Time) tea.Msg {
+			// ...and include a copy of that tag value in the message.
+			return exitMsg(m.tag)
+		})
+	case exitMsg:
+		// If the tag in the message doesn't match the tag on the model then we
+		// know that this message was not the last one sent and another is on
+		// the way. If that's the case we know, we can ignore this message.
+		// Otherwise, the debounce timeout has passed and this message is a
+		// valid debounced one.
+		if int(msg) == m.tag {
+			return m, tea.Quit
+		}
+	}
+
+	return m, nil
+}
+
+func (m model) View() string {
+	return fmt.Sprintf("Key presses: %d", m.tag) +
+		"\nTo exit press any key, then wait for one second without pressing anything."
+}
+
+func main() {
+	if err := tea.NewProgram(model{}).Start(); err != nil {
+		fmt.Println("uh oh:", err)
+		os.Exit(1)
+	}
+}
diff --git a/examples/sequence/main.go b/examples/sequence/main.go
new file mode 100644
index 0000000..79f7e23
--- /dev/null
+++ b/examples/sequence/main.go
@@ -0,0 +1,40 @@
+package main
+
+// A simple example illustrating how to run a series of commands in order.
+
+import (
+	"fmt"
+	"os"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+type model struct{}
+
+func (m model) Init() tea.Cmd {
+	return tea.Sequence(
+		tea.Println("A"),
+		tea.Println("B"),
+		tea.Println("C"),
+		tea.Quit,
+	)
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg.(type) {
+	case tea.KeyMsg:
+		return m, tea.Quit
+	}
+	return m, nil
+}
+
+func (m model) View() string {
+	return ""
+}
+
+func main() {
+	if err := tea.NewProgram(model{}).Start(); err != nil {
+		fmt.Println("Uh oh:", err)
+		os.Exit(1)
+	}
+}
diff --git a/exec.go b/exec.go
index 587d91a..53bf5d4 100644
--- a/exec.go
+++ b/exec.go
@@ -39,7 +39,7 @@ func Exec(c ExecCommand, fn ExecCallback) Cmd {
 //	c := exec.Command("vim", "file.txt")
 //
 //	cmd := ExecProcess(c, func(err error) Msg {
-//	    return VimFinishedMsg{err: error}
+//	    return VimFinishedMsg{err: err}
 //	})
 //
 // Or, if you don't care about errors, you could simply:
diff --git a/key.go b/key.go
index 07bc651..8ebaeeb 100644
--- a/key.go
+++ b/key.go
@@ -543,28 +543,29 @@ func readInputs(input io.Reader) ([]Msg, error) {
 
 		// Is the alt key pressed? If so, the buffer will be prefixed with an
 		// escape.
+		alt := false
 		if len(runes) > 1 && runes[0] == 0x1b {
-			msgs = append(msgs, KeyMsg(Key{Alt: true, Type: KeyRunes, Runes: runes[1:]}))
-			continue
+			alt = true
+			runes = runes[1:]
 		}
 
 		for _, v := range runes {
 			// Is the first rune a control character?
 			r := KeyType(v)
 			if r <= keyUS || r == keyDEL {
-				msgs = append(msgs, KeyMsg(Key{Type: r}))
+				msgs = append(msgs, KeyMsg(Key{Type: r, Alt: alt}))
 				continue
 			}
 
 			// If it's a space, override the type with KeySpace (but still include
 			// the rune).
 			if r == ' ' {
-				msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}}))
+				msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}, Alt: alt}))
 				continue
 			}
 
 			// Welp, just regular, ol' runes.
-			msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}}))
+			msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}, Alt: alt}))
 		}
 	}
 
diff --git a/tea.go b/tea.go
index 28065a0..547e011 100644
--- a/tea.go
+++ b/tea.go
@@ -337,13 +337,17 @@ func (p *Program) StartReturningModel() (Model, error) {
 		p.input = f
 	}
 
-	// Listen for SIGINT. Note that in most cases ^C will not send an
-	// interrupt because the terminal will be in raw mode and thus capture
-	// that keystroke and send it along to Program.Update. If input is not a
-	// TTY, however, ^C will be caught here.
+	// 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.
 	go func() {
 		sig := make(chan os.Signal, 1)
-		signal.Notify(sig, syscall.SIGINT)
+		signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
 		defer func() {
 			signal.Stop(sig)
 			close(sigintLoopDone)
@@ -528,6 +532,17 @@ func (p *Program) StartReturningModel() (Model, error) {
 			case execMsg:
 				// NB: this blocks.
 				p.exec(msg.cmd, msg.fn)
+
+			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():
+						}
+					}
+				}()
 			}
 
 			// Process internal messages for the renderer.
@@ -732,3 +747,14 @@ func (p *Program) Printf(template string, args ...interface{}) {
 		messageBody: fmt.Sprintf(template, args...),
 	}
 }
+
+// sequenceMsg is used interally to run the the given commands in order.
+type sequenceMsg []Cmd
+
+// Sequence runs the given commands one at a time, in order. Contrast this with
+// Batch, which runs commands concurrently.
+func Sequence(cmds ...Cmd) Cmd {
+	return func() Msg {
+		return sequenceMsg(cmds)
+	}
+}


Test patch:
diff --git a/key_test.go b/key_test.go
index b74f06b..869b32f 100644
--- a/key_test.go
+++ b/key_test.go
@@ -134,6 +134,24 @@ func TestReadInput(t *testing.T) {
 				},
 			},
 		},
+		"alt+enter": {
+			[]byte{'\x1b', '\r'},
+			[]Msg{
+				KeyMsg{
+					Type: KeyEnter,
+					Alt:  true,
+				},
+			},
+		},
+		"alt+ctrl+a": {
+			[]byte{'\x1b', byte(keySOH)},
+			[]Msg{
+				KeyMsg{
+					Type: KeyCtrlA,
+					Alt:  true,
+				},
+			},
+		},
 	} {
 		t.Run(out, func(t *testing.T) {
 			msgs, err := readInputs(bytes.NewReader(td.in))