| name | dado-tui |
| description | Guide for building terminal UIs with the dado Go TUI library (github.com/atterpac/dado, built on its own widget core over gdamore/tcell). Use when creating TUI apps, authoring or wiring dado components, working with its theme system, key bindings, navigation/page stack, data binding, or async/background work in a dado-based app. Covers the component catalog, the core architectural patterns, and the conventions that keep theming and threading correct. |
Building TUIs with dado
dado is a Go TUI component toolkit built on its own widget core (in github.com/atterpac/dado/core) directly over gdamore/tcell/v2. It adds: a scoped theme system with live switching, ~50 components, a page-stack navigator, fluent key bindings, two-way data binding, and async helpers. Use this skill whenever you build or modify a dado-based TUI.
Module path: github.com/atterpac/dado. Requires Go 1.24+.
When to use this skill
- Starting a new dado TUI app (assemble the shell, push views).
- Authoring a new custom component/widget that draws itself.
- Wiring theme colors, key bindings, navigation, modals, forms, or tables.
- Running background work and updating the UI safely.
The five load-bearing rules
These are the conventions everything else depends on. Get them right and the rest is mechanical.
-
Read theme colors at draw time — never cache them in a field.
Live theme switching works because every Draw re-reads the active theme. Read via the component's scoped accessor (w.th().Bg() for widgets, cb.Theme().Bg() for views), or the package forwarders (theme.Bg(), which read theme.Default()). Storing a tcell.Color in a struct field breaks switching.
-
All UI mutation happens on the draw thread — marshal via theme.QueueUpdateDraw(func(){...}).
Do background work on a goroutine, then queue the UI update. async and effect deliver their callbacks on the UI thread for you; bus handlers and util.TaskRunner callbacks do not — wrap those yourself.
-
Register cleanup into a Subscriptions, exposed via Subs().
Every theme registration / subscription returns an unregister func(). Add it to the component's Subscriptions. ComponentBase.Stop() releases its own subs and the wrapped widget's subs automatically (LIFO, idempotent). This is how teardown stays leak-free.
-
Input handlers return a bool (true = consumed).
core.Widget.HandleKey and ComponentBase.SetInputHandler both take func(*tcell.EventKey) bool. Build one from key bindings with KeyBindings.BuildBool(). See reference/input-nav-layout.md.
-
Pick the right base type for what you're building.
- Authoring a self-drawing leaf widget → embed
widgetBase, call initWidget.
- Wrapping a finished primitive as a navigable view → hold a
*ComponentBase.
- A view backed by an async fetch (loading/error/success) →
*StatefulComponentBase[T].
See reference/architecture.md.
Minimal app skeleton
package main
import (
"github.com/atterpac/dado/layout"
"github.com/atterpac/dado/theme"
"github.com/atterpac/dado/theme/themes"
)
func main() {
theme.Default().SetTheme(themes.Get("tokyonight-night"))
app := layout.NewApp(layout.AppConfig{
TopBar: myHeader(),
ShowCrumbs: true,
BottomBar: layout.NewMenu(),
Debug: true,
})
app.EnableThemes(layout.ThemeOptions{})
app.Pages().Push(NewHomeView())
if err := app.Run(); err != nil {
panic(err)
}
}
The app shell stacks rows: TopBar → Crumbs → Pages (main area) → BottomBar. Navigation is a stack (Push/Pop/Replace), not URL routes. Pushing a view Stop()s the previous top and Start()s the new one, updates breadcrumbs, and pushes its Hints() into the menu.
Component selection cheat-sheet
| Need | Reach for |
|---|
| Frame/label any child | Panel |
| Centered dialog / confirm | Modal (presets in modal_presets.go) |
| Side / bottom slide-out | Drawer, BottomSheet |
| Tabular data + selection | Table; huge/streamed → VirtualList; editable grid → DataGrid |
| Hierarchy | Tree (lazy-load capable) |
| Form entry | FormBuilder (fluent) or Form (quick) + binding.FormBinding |
| Single/multi field input | TextField, TextArea, Select, MultiSelect, Checkbox, RadioGroup |
| Fuzzy pick / palette | Finder, Autocomplete, input.CommandBar |
| Charts / metrics | LineGraph, BarChart, Sparkline, Gauge, HeatMap, MetricCard |
| Transient feedback | ToastManager; long op → ProgressModal |
| Whole pre-built view | recipes.Dashboard, recipes.LogViewer, recipes.ResourceList[T] |
Full catalog with constructors, methods, and use cases: reference/components.md.
Reference files
Read the one matching your task:
reference/architecture.md — ComponentBase / widgetBase / StatefulComponentBase[T], the Draw prepare+paint split, Subscriptions, value/event/handler interfaces, and the canonical recipe for authoring a new component.
reference/theme.md — Provider / Default(), the full color-accessor palette, runtime switching & subscriptions, status colors, Nerd Font icons, the theme YAML schema, programmatic builder, and gradients.
reference/input-nav-layout.md — KeyBindings (BuildBool), vim helpers, ActionRegistry, CommandBar, the nav page stack / modals / breadcrumbs, the layout app shell / menu / statusbar, and the binding package (Value[T], FormBinding[T], TableBinding[T]).
reference/components.md — the full tiered component catalog (basic / intermediate / advanced).
reference/support.md — async, effect, bus, style, help, validators, util, clipboard, recipes, testutil, and the dado CLI.
Quick gotchas
components/README.md documents stale theme.HasStatus/theme.StatusColor — those don't exist. Use typed *Status handles (theme.DefineStatus). See reference/theme.md.
style.BorderSet custom glyphs are largely a no-op through Apply — core.Box draws borders from a global rune set. Hand-draw borders in your own Draw if you need custom glyphs.
- The
dado CLI does not scaffold projects (dado new does not exist). It only lists/previews themes and lists components. Bootstrap by hand.
- There are two different
ListNavigator interfaces (one in input, one in nav) — don't conflate them.