Skip to content

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.

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 (or go 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.