1092 lines
22 KiB
Markdown
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)
|