Files
clojure-tui/bubbletea-guide.md
2026-01-21 01:21:05 -05:00

1092 lines
22 KiB
Markdown

# Bubbletea TUI Framework Guide
A comprehensive guide to Charm's Bubbletea - a Go framework for building terminal user interfaces based on The Elm Architecture.
## Table of Contents
1. [Overview](#overview)
2. [The Elm Architecture](#the-elm-architecture)
3. [Core Concepts](#core-concepts)
4. [Messages and Commands](#messages-and-commands)
5. [Built-in Commands](#built-in-commands)
6. [Input Handling](#input-handling)
7. [Rendering](#rendering)
8. [Program Options](#program-options)
9. [Examples](#examples)
---
## Overview
Bubbletea is a functional, Elm-inspired framework for building terminal UIs in Go. It enforces a unidirectional data flow pattern that makes applications predictable, testable, and maintainable.
**Key characteristics:**
- Functional reactive programming model
- Immutable state updates
- Message-driven architecture
- All side effects handled through commands
- Frame-rate limited rendering (60 FPS default)
- Delta rendering for performance
---
## The Elm Architecture
Bubbletea implements the Elm Architecture pattern:
```
Input → Message → Update → Model → View → Render
Command → Message → (repeat)
```
The core principle: **Your model never directly performs I/O.** All side effects flow through Commands, creating predictable, testable code.
### The Three Core Methods
Every Bubbletea program implements the `tea.Model` interface:
```go
type Model interface {
Init() Cmd // Returns initial command
Update(Msg) (Model, Cmd) // Handles messages, returns new state
View() string // Renders current state as string
}
```
---
## Core Concepts
### Model
The model holds all application state. It should be a struct containing everything needed to render the UI:
```go
type model struct {
cursor int // Current cursor position
choices []string // Available options
selected map[int]struct{} // Selected items
quitting bool
}
```
### Init
Called once when the program starts. Returns an optional command to run:
```go
func (m model) Init() tea.Cmd {
// Return nil for no initial command
return nil
// Or return a command to start something
return tea.Batch(
spinner.Tick,
fetchInitialData,
)
}
```
### Update
Receives messages and returns updated model plus optional command:
```go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
}
}
return m, nil
}
```
### View
Returns a string representation of the current state:
```go
func (m model) View() string {
if m.quitting {
return "Goodbye!\n"
}
s := "What do you want for lunch?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress q to quit.\n"
return s
}
```
---
## Messages and Commands
### Messages (Msg)
Messages are events that trigger state updates. They can be:
**Built-in messages:**
- `tea.KeyMsg` - Keyboard input
- `tea.MouseMsg` - Mouse events
- `tea.WindowSizeMsg` - Terminal resize
- `tea.FocusMsg` - Terminal gained focus
- `tea.BlurMsg` - Terminal lost focus
- `tea.QuitMsg` - Program should exit
**Custom messages:**
```go
// Define your own message types
type tickMsg time.Time
type errMsg struct{ error }
type responseMsg struct {
status int
body string
}
```
### Commands (Cmd)
Commands are functions that perform I/O and return messages:
```go
// A Cmd is a function that returns a Msg
type Cmd func() Msg
```
**Creating commands:**
```go
// Simple command returning a message
func tick() tea.Msg {
time.Sleep(time.Second)
return tickMsg{}
}
// HTTP request command
func checkServer(url string) tea.Cmd {
return func() tea.Msg {
resp, err := http.Get(url)
if err != nil {
return errMsg{err}
}
defer resp.Body.Close()
return responseMsg{status: resp.StatusCode}
}
}
```
---
## Built-in Commands
### Program Control
```go
tea.Quit // Graceful shutdown (returns QuitMsg)
tea.Suspend // Suspend program (Unix, Ctrl+Z)
```
### Command Composition
```go
// Run multiple commands concurrently
tea.Batch(cmd1, cmd2, cmd3)
// Run commands sequentially (each waits for previous)
tea.Sequence(cmd1, cmd2, cmd3)
```
### Timers
```go
// Fire once after duration
tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
// Fire synchronized to system clock
tea.Every(time.Minute, func(t time.Time) tea.Msg {
return tickMsg(t)
})
```
### Screen Control
```go
tea.ClearScreen // Clear terminal
tea.EnterAltScreen // Full-window mode (no scrollback)
tea.ExitAltScreen // Return to normal mode
tea.ShowCursor // Make cursor visible
tea.HideCursor // Hide cursor
```
### Mouse Control
```go
tea.EnableMouseCellMotion // Clicks, release, drag
tea.EnableMouseAllMotion // All motion including hover
tea.DisableMouse // Disable mouse
```
### External Process Execution
```go
// Run external command (like $EDITOR)
cmd := exec.Command("vim", "file.txt")
tea.ExecProcess(cmd, func(err error) tea.Msg {
return editorFinishedMsg{err}
})
```
---
## Input Handling
### Keyboard Input (KeyMsg)
```go
case tea.KeyMsg:
switch msg.String() {
// Character keys
case "a", "b", "c":
// Special keys
case "enter", "tab", "backspace", "delete":
case "up", "down", "left", "right":
case "home", "end", "pgup", "pgdown":
// Function keys
case "f1", "f2", "f12":
// Control combinations
case "ctrl+c", "ctrl+d", "ctrl+z":
case "alt+enter", "shift+tab":
// Escape
case "esc":
}
// Check key type
switch msg.Type {
case tea.KeyRunes:
// Regular character input
char := string(msg.Runes)
case tea.KeyEnter:
case tea.KeyCtrlC:
}
```
### Mouse Input (MouseMsg)
```go
case tea.MouseMsg:
switch msg.Button {
case tea.MouseButtonLeft:
if msg.Action == tea.MouseActionPress {
// Handle left click at msg.X, msg.Y
}
case tea.MouseButtonWheelUp:
// Scroll up
case tea.MouseButtonWheelDown:
// Scroll down
}
```
### Window Resize
```go
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
```
---
## Rendering
### How Rendering Works
1. After each Update, View() is called
2. The returned string is queued for rendering
3. A ticker fires at configured FPS (default 60)
4. Renderer compares with previous frame (delta detection)
5. Only changed lines are written to terminal
### Rendering Tips
- Return plain strings from View()
- Use newlines to separate lines
- Use [Lipgloss](https://github.com/charmbracelet/lipgloss) for styling
- Terminal clears and redraws automatically
```go
func (m model) View() string {
// Build your view as a string
var b strings.Builder
b.WriteString("Header\n\n")
for _, item := range m.items {
b.WriteString(item + "\n")
}
b.WriteString("\nFooter")
return b.String()
}
```
---
## Program Options
Configure program behavior with options:
```go
p := tea.NewProgram(
initialModel,
tea.WithAltScreen(), // Use alternate screen buffer
tea.WithMouseCellMotion(), // Enable mouse (click/drag)
tea.WithMouseAllMotion(), // Enable all mouse events
tea.WithoutSignalHandler(), // Handle Ctrl+C yourself
tea.WithFPS(120), // Custom frame rate
tea.WithReportFocus(), // Get focus/blur events
tea.WithInput(customReader), // Custom input source
tea.WithOutput(customWriter), // Custom output destination
)
// Run the program
finalModel, err := p.Run()
```
---
## Examples
### Example 1: Simple Counter
A minimal example demonstrating the core pattern:
```go
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
count 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:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
m.count++
case "down", "j":
m.count--
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf(
"Count: %d\n\nPress up/down to change, q to quit.\n",
m.count,
)
}
func main() {
p := tea.NewProgram(model{count: 0})
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 2: Countdown Timer with Commands
Demonstrates async commands and timers:
```go
package main
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
)
type tickMsg time.Time
type model struct {
seconds int
done bool
}
func (m model) Init() tea.Cmd {
return tick()
}
func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case tickMsg:
m.seconds--
if m.seconds <= 0 {
m.done = true
return m, tea.Quit
}
return m, tick()
}
return m, nil
}
func (m model) View() string {
if m.done {
return "Time's up!\n"
}
return fmt.Sprintf("Countdown: %d seconds remaining...\n\nPress q to quit.\n", m.seconds)
}
func main() {
p := tea.NewProgram(model{seconds: 10})
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 3: HTTP Request
Async HTTP requests with error handling:
```go
package main
import (
"fmt"
"net/http"
"time"
tea "github.com/charmbracelet/bubbletea"
)
const url = "https://httpstat.us/200"
type statusMsg int
type errMsg struct{ error }
type model struct {
status int
err error
loading bool
}
func checkServer() tea.Msg {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return errMsg{err}
}
defer resp.Body.Close()
return statusMsg(resp.StatusCode)
}
func (m model) Init() tea.Cmd {
return checkServer
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case statusMsg:
m.status = int(msg)
m.loading = false
case errMsg:
m.err = msg.error
m.loading = false
}
return m, nil
}
func (m model) View() string {
if m.loading {
return "Checking server...\n"
}
if m.err != nil {
return fmt.Sprintf("Error: %v\n\nPress q to quit.\n", m.err)
}
return fmt.Sprintf("Server returned: %d\n\nPress q to quit.\n", m.status)
}
func main() {
p := tea.NewProgram(model{loading: true})
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 4: Multiple Choice Selection
Interactive list with cursor and selection:
```go
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
func initialModel() model {
return model{
choices: []string{"Pizza", "Sushi", "Tacos", "Burger"},
selected: make(map[int]struct{}),
}
}
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 "q", "ctrl+c":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case "enter", " ":
if _, ok := m.selected[m.cursor]; ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "What do you want for lunch?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress space to select, q to quit.\n"
return s
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 5: Multiple Views / State Machine
Switch between different screens:
```go
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type viewState int
const (
menuView viewState = iota
detailView
)
type model struct {
state viewState
cursor int
items []string
selected string
}
func initialModel() model {
return model{
state: menuView,
items: []string{"Option A", "Option B", "Option C"},
}
}
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 "q", "ctrl+c":
return m, tea.Quit
case "esc":
if m.state == detailView {
m.state = menuView
}
case "up", "k":
if m.state == menuView && m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.state == menuView && m.cursor < len(m.items)-1 {
m.cursor++
}
case "enter":
if m.state == menuView {
m.selected = m.items[m.cursor]
m.state = detailView
}
}
}
return m, nil
}
func (m model) View() string {
switch m.state {
case menuView:
return m.menuView()
case detailView:
return m.detailView()
}
return ""
}
func (m model) menuView() string {
s := "Select an option:\n\n"
for i, item := range m.items {
cursor := " "
if m.cursor == i {
cursor = ">"
}
s += fmt.Sprintf("%s %s\n", cursor, item)
}
s += "\nPress enter to select, q to quit.\n"
return s
}
func (m model) detailView() string {
return fmt.Sprintf(
"You selected: %s\n\nPress esc to go back, q to quit.\n",
m.selected,
)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 6: Component Composition
Using sub-components (like spinners):
```go
package main
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/spinner"
)
type model struct {
spinner spinner.Model
loading bool
result string
}
type doneMsg struct{ result string }
func doWork() tea.Msg {
time.Sleep(3 * time.Second)
return doneMsg{result: "Work complete!"}
}
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
return model{
spinner: s,
loading: true,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
doWork,
)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
case doneMsg:
m.loading = false
m.result = msg.result
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m model) View() string {
if m.loading {
return fmt.Sprintf("%s Loading...\n", m.spinner.View())
}
return fmt.Sprintf("%s\n\nPress q to quit.\n", m.result)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 7: Text Input Form
Multiple text inputs with focus management:
```go
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/textinput"
)
type model struct {
inputs []textinput.Model
focused int
done bool
}
func initialModel() model {
inputs := make([]textinput.Model, 3)
inputs[0] = textinput.New()
inputs[0].Placeholder = "Name"
inputs[0].Focus()
inputs[0].CharLimit = 50
inputs[1] = textinput.New()
inputs[1].Placeholder = "Email"
inputs[1].CharLimit = 100
inputs[2] = textinput.New()
inputs[2].Placeholder = "Message"
inputs[2].CharLimit = 500
return model{inputs: inputs}
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "tab", "shift+tab", "down", "up":
// Cycle focus
if msg.String() == "shift+tab" || msg.String() == "up" {
m.focused--
if m.focused < 0 {
m.focused = len(m.inputs) - 1
}
} else {
m.focused++
if m.focused >= len(m.inputs) {
m.focused = 0
}
}
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
case "enter":
if m.focused == len(m.inputs)-1 {
m.done = true
return m, tea.Quit
}
m.focused++
for i := range m.inputs {
if i == m.focused {
cmds = append(cmds, m.inputs[i].Focus())
} else {
m.inputs[i].Blur()
}
}
}
}
// Update all inputs
for i := range m.inputs {
var cmd tea.Cmd
m.inputs[i], cmd = m.inputs[i].Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if m.done {
return fmt.Sprintf(
"Submitted!\nName: %s\nEmail: %s\nMessage: %s\n",
m.inputs[0].Value(),
m.inputs[1].Value(),
m.inputs[2].Value(),
)
}
var b strings.Builder
b.WriteString("Contact Form\n\n")
for i, input := range m.inputs {
b.WriteString(input.View())
if i < len(m.inputs)-1 {
b.WriteString("\n")
}
}
b.WriteString("\n\nTab to move, Enter to submit, Ctrl+C to quit.\n")
return b.String()
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
### Example 8: Full-Screen with Alt Screen and Mouse
Full-window application with mouse support:
```go
package main
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
width int
height int
mouseX int
mouseY int
clicks 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:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.MouseMsg:
m.mouseX = msg.X
m.mouseY = msg.Y
if msg.Action == tea.MouseActionPress {
m.clicks++
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf(
"Window: %dx%d\nMouse: (%d, %d)\nClicks: %d\n\nClick anywhere! Press q to quit.",
m.width, m.height,
m.mouseX, m.mouseY,
m.clicks,
)
}
func main() {
p := tea.NewProgram(
model{},
tea.WithAltScreen(), // Full-screen mode
tea.WithMouseAllMotion(), // Track all mouse movement
)
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
```
---
## Key Architectural Patterns
### 1. Unidirectional Data Flow
All data flows in one direction: Input → Message → Update → Model → View
### 2. Immutability
Update returns a new model rather than mutating the existing one.
### 3. Commands for Side Effects
All I/O happens through Commands - never in Update or View.
### 4. Message Types for Events
Use custom message types to represent domain events.
### 5. Component Delegation
Sub-components handle their own Update and View:
```go
var cmd tea.Cmd
m.subComponent, cmd = m.subComponent.Update(msg)
return m, cmd
```
### 6. State Machine for Views
Use an enum to track which view is active:
```go
switch m.state {
case loading:
return loadingView(m)
case ready:
return mainView(m)
case error:
return errorView(m)
}
```
---
## Related Libraries
- **[Bubbles](https://github.com/charmbracelet/bubbles)** - Pre-built components (spinner, textinput, list, table, etc.)
- **[Lipgloss](https://github.com/charmbracelet/lipgloss)** - Styling and layout
- **[Harmonica](https://github.com/charmbracelet/harmonica)** - Smooth animations
- **[Glamour](https://github.com/charmbracelet/glamour)** - Markdown rendering
---
## Resources
- [Bubbletea GitHub](https://github.com/charmbracelet/bubbletea)
- [Official Examples](https://github.com/charmbracelet/bubbletea/tree/master/examples)
- [The Elm Architecture](https://guide.elm-lang.org/architecture/)
- [Charm Website](https://charm.sh)