Tutorial
Start Building your first project with bubbletea!
What This Tutorial Shows
This tutorial demonstrates how to build an interactive shopping list application using Bubble Tea, a Go framework for creating terminal user interfaces. You'll learn the core concepts of the Elm Architecture and how to apply them to create responsive, stateful terminal applications.
Bubble Tea is based on the functional design paradigms of The Elm Architecture, which provides a structured approach to managing application state and UI updates.
Understanding the Elm Architecture
Think of the Elm Architecture like a well-organized kitchen. You have your ingredients (the model), recipes that transform ingredients (the update function), and the final plated dish (the view). Each part has a specific role, and following this structure makes creating complex applications much more manageable.
This architecture pattern is perfect for terminal applications because it provides a predictable way to handle state changes and user interactions, making your code easier to reason about and maintain.
Here's how it works in Bubble Tea:
- Your application state is stored in a model
- User interactions generate messages that trigger updates
- The update function processes these messages and returns a new model
- The view function renders the current state to the terminal
- Commands can perform asynchronous operations like I/O
How It Works
Define Your Model
The model stores your application's state. For our shopping list, we need to track available items, cursor position, and selected items:
type model struct {
choices []string // items on the to-do list
cursor int // which item our cursor is pointing at
selected map[int]struct{} // which items are selected
}The selected field uses a map as a mathematical set to track which indices are selected.
Initialize the Application
Set up the initial state and start the Bubble Tea program:
func initialModel() model {
return model{
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
selected: make(map[int]struct{}),
}
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v", err)
os.Exit(1)
}
}The initialModel function returns a properly initialized model struct.
Handle User Input
The update function processes keyboard input and updates the model accordingly:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
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", " ":
// Toggle selection
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}This handles navigation, selection, and quitting the application.
Remember that Bubble Tea assumes control of stdin and stdout, so you can't use standard logging to stdout while your application is running. Use tea.LogToFile() for debugging instead .
Code Breakdown
The model struct defines all application state:
type model struct {
choices []string // Available options
cursor int // Current selection position
selected map[int]struct{} // Set of selected indices
}Using a map with empty struct values (struct{}) is a memory-efficient way to implement a set in Go, as maps provide O(1) lookups.
The Init() method returns an optional initial command:
func (m model) Init() tea.Cmd {
return nil // No initial I/O needed
}For simple applications, you can return nil, but more complex apps might return a command to fetch initial data or set up timers.
The update method handles different message types:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle keyboard input
// Other message types can be handled here
}
return m, nil
}The function returns the updated model and optionally a command to perform asynchronous operations.
The view method generates the terminal UI:
func (m model) View() string {
s := "What should we buy at the market?\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
}The view is reconstructed on each render based on the current model state.
Key Concepts Explained
Messages
Messages are how Bubble Tea communicates events to your application. They can represent keyboard input, timer ticks, file I/O completion, or any other event your app needs to handle.
// Example of defining custom message types
type itemSelectedMsg struct { index int }
type dataLoadedMsg struct { data []string }Commands
Commands are how you perform asynchronous operations in Bubble Tea. They return messages that get processed by your update function.
func loadData() tea.Cmd {
return func() tea.Msg {
// Simulate loading data
time.Sleep(1 * time.Second)
return dataLoadedMsg{data: []string{"Item 1", "Item 2"}}
}
}The Tea Program
The tea.Program is the runtime that manages your application's event loop, rendering, and updates.
func main() {
// Create a new program with your model
p := tea.NewProgram(initialModel())
// Run the program
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v", err)
os.Exit(1)
}
}Enhancing the Tutorial Application
Let's extend our shopping list with some additional features to make it more practical:
Adding Items
Add functionality to create new list items:
// Add to the KeyMsg handling in Update
case "a":
// Add a new item
return m, tea.Sequence(
tea.Printf("Adding new item..."),
addItemCmd("New item"),
)
// Define the add command
func addItemCmd(text string) tea.Cmd {
return func() tea.Msg {
return itemAddedMsg{text: text}
}
}
// Handle the message in Update
case itemAddedMsg:
m.choices = append(m.choices, msg.text)
return m, nilRemoving Items
Add functionality to remove selected items:
// Add to the KeyMsg handling
case "d":
// Delete selected items
var newChoices []string
for i, choice := range m.choices {
if _, selected := m.selected[i]; !selected {
newChoices = append(newChoices, choice)
}
}
m.choices = newChoices
m.selected = make(map[int]struct{}) // Clear selection
return m, nilDebugging Your Application
Since Bubble Tea controls stdin/stdout, debugging requires special approaches:
func main() {
// Enable debug logging to a file
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("Error setting up log file:", err)
os.Exit(1)
}
defer f.Close()
}
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v", err)
os.Exit(1)
}
}View logs in real-time with tail -f debug.log while your application runs.
Complete Code
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
choices []string
cursor int
selected map[int]struct{}
}
type itemAddedMsg struct {
text string
}
func initialModel() model {
return model{
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
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 "ctrl+c", "q":
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", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
case "a":
// Add a new item
return m, func() tea.Msg {
return itemAddedMsg{text: "New item"}
}
case "d":
// Delete selected items
var newChoices []string
for i, choice := range m.choices {
if _, selected := m.selected[i]; !selected {
newChoices = append(newChoices, choice)
}
}
m.choices = newChoices
m.selected = make(map[int]struct{})
return m, nil
}
case itemAddedMsg:
m.choices = append(m.choices, msg.text)
return m, nil
}
return m, nil
}
func (m model) View() string {
s := "What should we buy at the market?\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 ↑/↓ to navigate, space to select, " +
"a to add, d to delete, q to quit.\n"
return s
}
func main() {
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("Error setting up log file:", err)
os.Exit(1)
}
defer f.Close()
}
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v", err)
os.Exit(1)
}
}What's Next?
Now that you understand the basics of Bubble Tea, here are some directions for further learning:
-
I/O and Commands: Learn how to perform asynchronous operations like reading files or making HTTP requests using Commands.
-
Bubbles Components: Explore the Bubbles library for pre-built UI components like text inputs, viewports, and spinners.
-
Styling with Lip Gloss: Use the Lip Gloss library to add colors, styles, and layouts to your terminal UI.
-
Complex Applications: Study the Bubble Tea examples to see more complex applications and patterns.
-
Debugging Techniques: Learn advanced debugging with Delve in headless mode for troubleshooting your Bubble Tea applications .
For more advanced form handling, check out Huh?, a forms library built on Bubble Tea that simplifies creating complex forms and prompts .
Remember, the key to mastering Bubble Tea is practice. Start with simple applications and gradually add complexity as you become more comfortable with the architecture and patterns.
How is this guide?