bubbletea is a TUI framework designed for Go that is based on the Elm Architecture. It helps users build stateful, interactive terminal applications.

Since bubbletea is a framework, there are several components available to help create things like text inputs, lists, etc.

Architecture

bubbletea programs are based around the concept of a model that implements three methods:

  • Init() — a function that initializes the application (typically performing some initial IO)
  • Update() — a function that handles events and updates the model
  • View() — a function that renders the UI in the terminal.

bubbletea also has two other critical features: messages (Msg) and commands (Cmd). bubbletea communicates within the application using messages. If we need to pass data around, we do it in the form of a Msg. A Msg has the following signature:

type Msg interface{}, which means it can be anything.

A Cmd is a function that returns a Msg. If we need our application to run a function, we need to wrap it in a Cmd, since the bubbletea library ensures that Cmd don’t block and generally integrate into the overall application flow. A Cmd has the following signature:

type Cmd func() tea.Msg

Demo Application

I wrote a fairly basic to-do list application that demonstrates how bubbletea works. Here’s the code for it:

// this is a basic to-do list bubbletea application
package main
 
import (
	"context"
	"database/sql"
	"fmt"
	"os"
 
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"
	_ "modernc.org/sqlite"
)
 
const (
	dsn = "demo.db"
)
 
// defining db stuff
type todoService struct {
	db *sql.DB
}
 
func newTodoService(db *sql.DB) todoService {
	return todoService{
		db: db,
	}
}
 
func (td todoService) CreateItem(item string) (int, error) {
	ctx := context.Background()
 
	qry := `
	INSERT INTO items (item)
	VALUES (?)
	`
 
	res, err := td.db.ExecContext(ctx, qry, item)
 
	if err != nil {
		return 0, err
	}
 
	id, err := res.LastInsertId()
 
	if err != nil {
		return 0, err
	}
 
	return int(id), nil
}
 
func (td todoService) GetAllItems() ([]string, error) {
	ctx := context.Background()
 
	qry := `
	SELECT item	
	FROM items
	`
 
	rows, err := td.db.QueryContext(ctx, qry)
 
	if err != nil {
		return nil, err
	}
 
	defer rows.Close()
 
	var items []string
 
	for rows.Next() {
		var item string
 
		if err := rows.Scan(&item); err != nil {
			return nil, err
		}
 
		items = append(items, item)
	}
 
	return items, rows.Err()
}
 
// a message to represent that the initial items have been loaded
type itemsLoadedMsg []string
 
// a message to represent an error
type errMsg struct{ err error }
 
// a message to represent that an item has been created
type itemCreatedMsg struct{}
 
type model struct {
	todoList    []string
	todoService todoService
	input       textinput.Model
	err         error
}
 
// constructor for the initial model state
func initialModel(db *sql.DB) model {
	svc := newTodoService(db)
	ti := textinput.New()
	ti.Placeholder = "What needs to be done?"
 
	//ti.Focus() is a tea.Cmd that sets the focus state on the ti.Model(), which allows for keyboard input
	ti.Focus()
	return model{
		todoList:    []string{},
		todoService: svc,
		input:       ti,
	}
}
 
// loadItems is a tea.Cmd that retrieves all items from the database
func (m model) loadItems() tea.Msg {
	items, err := m.todoService.GetAllItems()
	if err != nil {
		return errMsg{err}
	}
	return itemsLoadedMsg(items)
}
 
func (m model) Init() tea.Cmd {
	// When the application starts, we want to load the to-do items.
	return m.loadItems
}
 
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
 
	switch msg := msg.(type) {
	case tea.KeyMsg:
		// Handle key presses
		switch msg.String() {
		case "ctrl+c", "esc":
			return m, tea.Quit
 
		case "enter":
			if m.input.Value() != "" {
				item := m.input.Value()
				m.input.Reset() // Clear the input field immediately
 
				// Return a command to create the item in the database
				return m, func() tea.Msg {
					if _, err := m.todoService.CreateItem(item); err != nil {
						return errMsg{err}
					}
					// On success, we'll send a message to trigger a reload of the list
					return itemCreatedMsg{}
				}
			}
		}
 
	// Handle the message that our initial items have been loaded
	case itemsLoadedMsg:
		m.todoList = msg
 
	// Handle the message that a new item has been created
	case itemCreatedMsg:
		// We've successfully created an item, now reload the list from the DB
		return m, m.loadItems
 
	case errMsg:
		m.err = msg.err
		return m, nil
	}
 
	//update the input (handling typing/blinking)
	m.input, cmd = m.input.Update(msg)
	return m, cmd
 
}
 
func (m model) View() string {
	if m.err != nil {
		return fmt.Sprintf("Error: %v", m.err)
	}
 
	s := "--- My ToDo List ---\n\n"
 
	for _, item := range m.todoList {
		s += fmt.Sprintf("- %s\n", item)
	}
 
	// Render the text input and help text
	s += fmt.Sprintf("\n%s\n\n", m.input.View())
	s += "(esc to quit)"
 
	return s
}
 
func main() {
	db, err := sql.Open("sqlite", dsn)
 
	if err != nil {
		fmt.Printf("Error connecting to db: %v", err)
		os.Exit(1)
	}
 
	defer db.Close()
 
	p := tea.NewProgram(initialModel(db))
 
	if _, err := p.Run(); err != nil {
		fmt.Printf("Error: %v", err)
		os.Exit(1)
	}
}