Quick start¶
This page builds a small app: a filterable list of cities that opens a detail screen for the one you pick. Along the way it covers the four things every tuilib app does: implement a screen, lay it out, theme it, and hand it to the app shell.
Install¶
mkdir cities && cd cities
go mod init example.com/cities
go get github.com/jsdrews/tuilib@latest
The whole program¶
Save this as main.go. It is the repo's examples/quickstart/main.go, which
is compiled in CI, so it builds against the current API.
// Command quickstart is the program the documentation's quick start builds:
// a filterable list of cities that opens a detail screen for the one you pick,
// running under the app shell.
//
// go run ./examples/quickstart
package main
import (
"fmt"
"os"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/jsdrews/tuilib/pkg/app"
"github.com/jsdrews/tuilib/pkg/help"
"github.com/jsdrews/tuilib/pkg/inspector"
"github.com/jsdrews/tuilib/pkg/layout"
"github.com/jsdrews/tuilib/pkg/list"
"github.com/jsdrews/tuilib/pkg/screen"
"github.com/jsdrews/tuilib/pkg/theme"
)
type city struct {
Name, Country, Region, Population string
}
var cities = []city{
{"London", "United Kingdom", "Europe", "8.9M"},
{"Tokyo", "Japan", "Asia", "13.9M"},
{"Madrid", "Spain", "Europe", "3.3M"},
{"Lima", "Peru", "South America", "10.0M"},
{"Nairobi", "Kenya", "Africa", "4.4M"},
{"Toronto", "Canada", "North America", "2.8M"},
}
// cityList is the root screen: one filterable list filling the body.
type cityList struct {
t theme.Theme
list list.Model
}
func (s *cityList) Title() string { return "Cities" }
func (s *cityList) Init() tea.Cmd { return nil }
func (s *cityList) OnEnter(any) tea.Cmd { return nil }
func (s *cityList) IsCapturingKeys() bool { return s.list.IsCapturingKeys() }
func (s *cityList) Layout() layout.Node { return layout.Sized(&s.list) }
func (s *cityList) Help() []key.Binding { return s.list.Help() }
func (s *cityList) HelpSections() []help.Section { return s.list.HelpSections() }
func (s *cityList) Update(msg tea.Msg) (screen.Screen, tea.Cmd) {
// Enter and double-click are one verb: open the selection.
if s.list.IsActivate(msg) {
if i, ok := s.list.SelectedIndex(); ok {
return s, screen.Push(newCityDetail(cities[i], s.t))
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *cityList) SetTheme(t theme.Theme) {
s.t = t
st := s.list.State()
opts := t.List()
opts.Title = "Cities"
opts.Filterable = true
for _, c := range cities {
opts.Items = append(opts.Items, c.Name)
}
s.list = list.New(opts)
s.list.Restore(st)
}
// cityDetail is pushed on top of the list. Esc pops it; the shell does that.
type cityDetail struct {
city city
ins inspector.Model
}
func newCityDetail(c city, t theme.Theme) *cityDetail {
s := &cityDetail{city: c}
s.SetTheme(t)
return s
}
func (s *cityDetail) Title() string { return s.city.Name }
func (s *cityDetail) Init() tea.Cmd { return nil }
func (s *cityDetail) OnEnter(any) tea.Cmd { return nil }
func (s *cityDetail) IsCapturingKeys() bool { return s.ins.IsCapturingKeys() }
func (s *cityDetail) Layout() layout.Node { return layout.Sized(&s.ins) }
func (s *cityDetail) Help() []key.Binding { return s.ins.Help() }
func (s *cityDetail) HelpSections() []help.Section { return s.ins.HelpSections() }
func (s *cityDetail) Update(msg tea.Msg) (screen.Screen, tea.Cmd) {
var cmd tea.Cmd
s.ins, cmd = s.ins.Update(msg)
return s, cmd
}
func (s *cityDetail) SetTheme(t theme.Theme) {
st := s.ins.State()
opts := t.Inspector()
opts.Title = s.city.Name
opts.Fields = []inspector.Field{
{Label: "Country", Value: s.city.Country},
{Label: "Region", Value: s.city.Region},
{Label: "Population", Value: s.city.Population},
}
s.ins = inspector.New(opts)
s.ins.Restore(st)
}
func main() {
m := app.New(app.Options{
Root: &cityList{},
Themes: theme.All(),
ThemeKey: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
Mouse: app.MouseClick,
Version: "quickstart",
})
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Run it:
go run .
Then try:
| Press | What happens |
|---|---|
↓/j, ↑/k |
move the cursor |
/, type o, enter |
filter the list, then hand the keys back to it |
enter (or double-click) |
push the detail screen |
esc |
pop back; the cursor and filter are where you left them |
t |
cycle themes; nothing you did is lost |
? |
open the key overlay, grouped by what each key does |
q |
quit |
What each piece is doing¶
A screen is eight methods¶
screen.Screen is the unit the stack manages:
| Method | Purpose |
|---|---|
Title() |
the screen's crumb in the breadcrumb trail |
Init() |
runs once when the screen is constructed |
Update(msg) |
handles one message, returns the screen and a command |
Layout() |
returns a layout.Node tree the shell renders in the body |
Help() |
the bindings shown in the footer and the ? overlay |
OnEnter(result) |
runs each time the screen becomes the top of the stack |
SetTheme(t) |
rebuilds themed components when the palette changes |
IsCapturingKeys() |
true while a text field owns the keyboard |
HelpSections() is optional. Forwarding it from the component groups the
? overlay by function (Navigate, Filter, …) instead of one flat list. See
Help and key hints.
Layout is declared, not computed¶
Layout() returns layout.Sized(&s.list): the list fills the body. The
shell gives it a rect after subtracting the breadcrumb and statusbar, so the
screen never knows the terminal size. A two-pane screen would return:
return layout.HStack(
layout.Fixed(28, layout.Sized(&s.list)),
layout.Flex(1, layout.Sized(&s.detail)),
)
See Layout.
Components start from the theme¶
t.List() returns list.Options with every color, glyph and key binding
filled in from the palette. You set only what's specific to this list
(Title, Items, Filterable) and call list.New. Never set Width or
Height on a component you place with layout.Sized; the layout engine sizes
it.
SetTheme rebuilds, State/Restore keep what the user did¶
When the user presses t, the shell calls SetTheme on every screen in the
stack. Each screen rebuilds its components from the new theme and carries
their state across:
st := s.list.State() // items, cursor, filter, marks, scroll…
s.list = list.New(opts)
s.list.Restore(st)
The shell calls SetTheme once before the first render too. A State taken
from a component that was never built restores nothing, so the same three
lines double as the constructor. See Theming and state.
Navigation is a stack¶
screen.Push(child) puts a screen on top. esc pops it (the shell does
this), and screen.Pop(value) pops it with a result that arrives in the
parent's OnEnter(value). See Screens and navigation.
IsActivate is "open the selection"¶
s.list.IsActivate(msg) is true for enter (when the filter isn't taking
input) and for a double-click on the list. Routing both through one
predicate means mouse and keyboard can never drift apart.
The shell owns the global keys¶
q, esc-to-pop, ?, t and ctrl+z are handled by app.Model, and are
suppressed while IsCapturingKeys() is true. That's why typing q into the
filter doesn't quit.
Next steps¶
- Read Architecture for the full picture.
- Run
task examples(orgo run ./examples/launcher) in a clone of the repo and open any demo; each one names the directory its code lives in. - Browse the component reference.