hone's TUI is built with Bubble Tea. This page covers the patterns used throughout internal/tui/.


Router stack

Navigation uses a stack of tea.Model values managed by router.go.

// Push a new view onto the stack
return m, tui.Push(tui.NewAddModel(db, profileDir, ""))

// Pop back to the previous view
return m, tui.Pop()

The router receives all messages. It forwards them to the top-of-stack model and handles PushMsg / PopMsg to transition between views.

When a model exits (e.g. user presses q), it emits Pop(). When it wants to navigate forward, it emits Push(newModel). No model holds a reference to its parent.


Standalone vs embedded models

Some models can run both as a full standalone program and embedded inside a parent model. The AddModel is an example:

type AddModel struct {
    standalone bool
    // ...
}

func NewAddModel(db *sqlx.DB, profileDir, prefill string) AddModel {
    return AddModel{standalone: true, ...}
}

func (m AddModel) exit() tea.Cmd {
    if m.standalone {
        return tea.Quit
    }
    return Pop()
}

When embedded (e.g. opened from the playlist picker), standalone is set to false and exit pops back to the parent instead of quitting.


Communicating between models

Models communicate via typed message values. When AddModel successfully adds a problem, it emits problemAddedMsg{}. The parent model handles this in its Update:

case problemAddedMsg:
    return m, m.loadCmd() // reload the problem list

This is the standard Bubble Tea pattern — no callbacks, no shared mutable state.


Running a program

Two helpers in run.go:

// Full-screen (altscreen) — used for the main dashboard
tui.Run(model)

// Inline — output scrolls in the terminal, visible after exit
// Used for hone import (progress output)
tui.RunInline(model)

colorTable

Use colorTable from color_table.go whenever table cells contain lipgloss-styled text:

t := newColorTable([]colorColumn{
    {header: "Title", width: 40},
    {header: "Difficulty", width: 10},
})
for _, row := range problems {
    t.addRow([]string{
        row.Title,
        difficultyStyle.Render(row.Difficulty),
    })
}
view := t.render()

colorTable uses lipgloss.Width() for all cell measurements, which correctly handles ANSI escape sequences. Never use bubbles/table for styled content.


Key maps

All key bindings are defined in keys.go as key.Map structs. Each model has its own map. They're passed to a bubbles/help model for the help bar:

type statsKeys struct {
    Practice key.Binding
    Add      key.Binding
    Help     key.Binding
    Quit     key.Binding
}

func (k statsKeys) ShortHelp() []key.Binding {
    return []key.Binding{k.Practice, k.Add, k.Help, k.Quit}
}

List sizing

When the terminal resizes, all bubbles/list models need their height updated. The pattern is a resizeList method that computes the available height:

func (m *MyModel) resizeList(height int) {
    const reserved = 3 // blank line + content line + help line
    m.list.SetHeight(height - reserved)
}

Called in Update when a tea.WindowSizeMsg arrives.


Splash screen

SplashModel wraps the router and shows the ASCII art intro before the first WindowSizeMsg is forwarded to the inner model. The inner model's Init() is called once the splash finishes, so it's correctly sized when activated.

The gradient animation uses per-character HSL coloring:

hue := math.Mod(float64(col)*1.5+float64(frame)*3, 360)
color := hslHex(hue, 1.0, 0.65)

frame is incremented on each 50ms tick, sweeping the hue across the full rainbow.