Patterns that come up often. Each recipe shows the smallest code that solves the problem; copy, adapt, ship.
If you haven't read GETTING_STARTED.md yet, do
that first — these recipes assume you know what Program, Cmd, Sub,
and Update are.
- Confirm before quit
- Debounced search
- A scrollable, virtualized list
- A clock
- Run multiple async tasks in parallel
- Catch a panic from a background task
- Fan a single key into multiple submodules
- Save / restore window state on quit
- A draggable horizontal splitter
- Test an Update without booting a terminal
- Route arrow keys to the focused pane
- Visible focus on every interactive widget
Catch the quit message, show a modal, and only really quit on the second confirmation.
sealed type Msg {
case TryQuit()
case ConfirmQuit()
case CancelQuit()
case ...
}
struct Model(Confirming bool, /* ... */)
func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] = msg match {
case TryQuit() =>
if (m.Confirming) (m, QuitCmd[Msg]())
else (m.Copy(Confirming = true), NoCmd[Msg]())
case ConfirmQuit() => (m, QuitCmd[Msg]())
case CancelQuit() => (m.Copy(Confirming = false), NoCmd[Msg]())
// ...
}
func view(m Model) Widget {
val base = mainView(m)
if m.Confirming {
return Stack(ArrayOf[Widget](
base,
Modal(40, 6, ConfirmDialog("Quit?", "Unsaved changes will be lost.", true)),
))
}
return base
}
Bind q and Ctrl-C both to TryQuit() so neither one bypasses the
confirm prompt. Esc while Confirming returns CancelQuit().
Don't fire a query on every keystroke. Instead, after each keystroke
schedule a Cmd for 300 ms in the future and only fire the query if no
newer keystroke arrives by then. The simplest way is to remember the
keystroke's "generation" — increment on each TypeChar, then ignore
late-firing search messages whose generation is stale.
struct Model(
Query string,
Gen int, // increments on every keystroke
Hits Array[string],
)
sealed type Msg {
case TypeChar(C rune)
case TypeBackspace()
case Search(Gen int) // fires from AfterDelay; gen lets us drop stale
case GotResults(Gen int, Hits Array[string])
}
func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] = msg match {
case TypeChar(c) => {
val q = m.Query + string(c)
val gen = m.Gen + 1
(m.Copy(Query = q, Gen = gen),
AfterDelay[Msg](Milliseconds(int64(300)), () => Search(Gen = gen)))
}
case TypeBackspace() => /* same shape as above */
case Search(g) =>
if (g != m.Gen) (m, NoCmd[Msg]()) // stale — drop
else (m, runQuery(m.Query, g))
case GotResults(g, hits) =>
if (g != m.Gen) (m, NoCmd[Msg]())
else (m.Copy(Hits = hits), NoCmd[Msg]())
}
func runQuery(q string, gen int) Cmd[Msg] =
Async[Msg](
() => doSearch(q),
(hits) => GotResults(Gen = gen, Hits = hits),
)
Generation-based debounce composes with any async backend; the same trick works for live-validation forms, autocomplete, and "save on stop typing".
Wrap a tall widget in ScrollableViewport(inner, offset, contentHeight).
Drive offset from your model — clamp on update, paint on view.
struct Model(Items Array[string], Sel int, Top int)
func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] = msg match {
case Down() => {
val sel = clampInt(m.Sel + 1, 0, m.Items.Length() - 1)
val top = scrollToShow(sel, m.Top, viewportRows)
(m.Copy(Sel = sel, Top = top), NoCmd[Msg]())
}
case Up() => /* same, with -1 */
// ...
}
func view(m Model) Widget {
val full = SelectListOf(m.Items, m.Sel)
return ScrollableViewport(full, m.Top, m.Items.Length())
}
func scrollToShow(sel int, top int, rows int) int =
if (sel < top) sel
else if (sel >= top + rows) sel - rows + 1
else top
For very large lists (>10k items), don't pass them all into
SelectListOf — slice your model into a window of viewportRows items
yourself and pass that. The runtime won't render rows outside the
viewport, but it does walk the array building the widget.
Use a TickSub to fire a message once per second. The View reads
the time from the model and renders.
sealed type Msg {
case Tick(Now Instant)
// ...
}
func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] = msg match {
case Tick(now) => (m.Copy(Now = now), NoCmd[Msg]())
// ...
}
func main() {
val sub = TickSub[Msg](
Interval = Seconds(int64(1)),
Make = () => Tick(Now = Now()),
)
val _ = RunWithSub[Model, Msg](program, (ev) => keyToMsg(ev), sub)
}
Now() is from martianoff/gala/time_utils. The runtime polls the
ticker between stdin reads, so the clock advances even while the user
isn't typing.
Emit a BatchCmd containing several FutureCmds (or use the
Async/AsyncTry helpers, which produce one). The runtime polls all
pending futures every loop iteration; whichever resolves first is
dispatched first.
case StartFanOut() => (m.Copy(Loading = true), Batch[Msg](
Async[Msg](() => fetchUsers(), (xs) => GotUsers(Users = xs)),
Async[Msg](() => fetchProjects(), (xs) => GotProjects(Projects = xs)),
Async[Msg](() => fetchHealth(), (h) => GotHealth(H = h)),
))
The Got* arms each update one chunk of the model. When all three
have arrived, you can flip Loading = false — track a counter or
inspect the model fields.
Use AsyncTry instead of Async. It takes both an "ok" and an "err"
callback and routes panics through the err path automatically.
case StartImport() =>
(m.Copy(Importing = true),
AsyncTry[Msg, ImportResult](
() => parseHugeCsv(m.Path),
(result) => ImportOk(R = result),
(errMsg) => ImportFailed(Msg = errMsg),
))
The () => parseHugeCsv(...) runs as a background Future[T] on the
runtime's ExecutionContext; if it panics, the recovered message becomes
the errMsg argument to your err callback. Your Update handles
ImportFailed like any normal message.
Use BatchSubs to compose subs and MapSubs to lift child messages
into parent messages.
sealed type Msg {
case AppKey(K KeyEvent)
case ChildA(C ChildAMsg)
case ChildB(C ChildBMsg)
}
func main() {
val subA = MapSubs(
OnKey[ChildAMsg]((ev) => childAKey(ev)),
(ca) => ChildA(C = ca),
)
val subB = MapSubs(
OnKey[ChildBMsg]((ev) => childBKey(ev)),
(cb) => ChildB(C = cb),
)
val sub = BatchSubs(ArrayOf[Sub[Msg]](subA, subB))
// ...
}
Each keystroke becomes both a ChildA(...) and a ChildB(...) —
useful when you want a global keymap to coexist with a focused-pane
keymap, both seeing every key.
You don't get a teardown hook from the runtime, but Update is the
only place state changes — so persist on every meaningful change. To
avoid file IO on every keystroke, debounce as in the search recipe.
case Resize(w, h) =>
(m.Copy(Width = w, Height = h),
WriteFileCmd[Msg]("~/.myapp/state.json",
marshalState(m), 0644,
(_) => SavedState(),
(e) => SaveFailed(Msg = e)))
To restore at startup, do the read in main before constructing the
program:
val savedJson = readFileOrEmpty("~/.myapp/state.json")
val initialModel = unmarshalStateOr(savedJson, defaultModel())
val program = Program[Model, Msg](initialModel, /* ... */)
readFileOrEmpty is yours to write; os.ReadFile from the Go stdlib
works fine.
Track the split column in the model. On left-mouse-press inside the splitter row, enter a "dragging" state; on mouse motion, update the split position; on release, leave dragging.
struct Model(SplitCol int, Dragging bool)
func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] = msg match {
case MouseDown(x, y) =>
if (y == splitRowY(m)) (m.Copy(Dragging = true), NoCmd[Msg]())
else (m, NoCmd[Msg]())
case MouseMove(x, _) =>
if (m.Dragging) (m.Copy(SplitCol = clampInt(x, 10, 80)), NoCmd[Msg]())
else (m, NoCmd[Msg]())
case MouseUp() => (m.Copy(Dragging = false), NoCmd[Msg]())
}
func view(m Model) Widget = Row(ArrayOf[LayoutChild](
Fixed(m.SplitCol, leftPane),
Fixed(1, FillCh('│')),
Flex(1, rightPane),
))
Drive these messages from your MegaInputToMsg-style adapter — match
on MouseInput(m) and emit MouseDown(m.X, m.Y) / MouseMove(...) /
MouseUp() based on m.Pressed and m.Btn.
StepAll drives a Program through a list of messages and returns
the final model + commands emitted. No stdin, no background Futures.
func TestIncrementsThenQuits(t T) T {
val program = Program[Model, Msg](
Model(N = 0),
(m, msg) => update(m, msg),
(m) => view(m),
)
val (final, cmds) = StepAll(program, ArrayOf[Msg](Inc(), Inc(), Quit()))
val t1 = Eq(t, final.N, 2)
return IsTrue(t1, cmds.Exists((c) => IsQuit(c)))
}
For visual regressions: render view(model) to a string with Snapshot
and compare against a fixture. See GETTING_STARTED.md § 5.
Apps with multiple focusable panes (sidebar / data table / log drawer / modal buttons / ...) all face the same problem: an arrow key means something different to each pane. Hand-rolling the switch on every update is error-prone — and makes it easy to leave a pane with no handler at all (the bug that made arrow keys "do nothing" on the demo's table view for one whole release).
Use FocusManager.Route[T] instead. Pass an array of (paneID, handler)
cases and a fallback. The first case whose pane matches the
FocusManager's current pane fires; the fallback runs if nothing matches.
import . "github.com/martianoff/gala-tui"
import "github.com/martianoff/gala-tui/state"
func arrowDown(m AppModel) AppModel =
m.Focus.Route[AppModel](ArrayOf[state.FocusedCase[AppModel]](
state.OnPane[AppModel]("sidebar", () => moveSidebar(m, +1)),
state.OnPane[AppModel]("table", () => moveTableCursor(m, +1)),
state.OnPane[AppModel]("drawer", () => scrollDrawer(m, +1)),
), m) // fallback: unchanged model when no pane matches
Each handler is a thunk so closures capture whatever they need. The
result type T is whatever the caller wants — AppModel, AppMsg,
Tuple[AppModel, Cmd[AppMsg]], etc.
For the simpler "is THIS one pane focused?" case, use
FocusManager.When[T] instead:
val onEsc = m.Focus.When[AppModel](
"drawer",
() => m.Copy(ShowDrawer = false),
m,
)
The naming convention (Pane = "sidebar") is just a string — it must
match the pane IDs you registered with state.NewFocusManager(...).
Every interactive widget ships with a focused bool = false parameter
that brightens the cursor row when keyboard focus is on that widget:
val isFocused = m.Focus.IsFocused("table")
val table = DataTableView(m.BuildsTable, isFocused)
val list = SelectListOf(m.Items, m.Sel, isFocused)
val tree = TreeFocused(m.Pipelines, m.Cursor, isFocused)
val menu = MenuView(m.Menu, isFocused)
val cal = CalendarView(m.Cal, isFocused)
val files = FileBrowserView(m.Browser, isFocused)
val form = FormView(m.Form, isFocused)
val tabs = Tabs(titles, bodies, m.Tab, isFocused)
val input = Input(m.Value, m.Cursor, "type here", isFocused)
val drop = DropdownView(m.Drop, isFocused)
val pal = PaletteView(m.Palette, isFocused) // defaults to true — palette is modal
Default false keeps existing call sites working. When focused = true,
the cursor row uses BrightYellow + Bold + Reverse so the user sees at
a glance which widget the keyboard is driving.
Threading m.Focus.IsFocused("...") through every widget call is
repetitive — and gets the pane name wrong silently if you typo. Use
NewFocusBuilder(m.Focus) to fold the lookup into each widget call.
The pane name moves to the front, the boolean disappears:
val ui = NewFocusBuilder(m.Focus)
return Row(ArrayOf[LayoutChild](
Fixed(20, ui.SelectListOf("sidebar", m.NavItems, m.NavSel)),
Flex(1, ui.DataTable("table", m.Table)),
))
Methods on FocusBuilder mirror every interactive widget — pane name
first, then the widget's own arguments:
| FocusBuilder | Equivalent raw call |
|---|---|
ui.DataTable("table", dt) |
DataTableView(dt, ui.IsFocused("table")) |
ui.SelectListOf("nav", labels, sel) |
SelectListOf(labels, sel, ui.IsFocused("nav")) |
ui.Tree("pipelines", root, cursor) |
TreeFocused(root, cursor, ui.IsFocused("pipelines")) |
ui.Menu("file-menu", m) |
MenuView(m, ui.IsFocused("file-menu")) |
ui.Tabs("tabs", titles, bodies, sel) |
Tabs(titles, bodies, sel, ui.IsFocused("tabs")) |
ui.Calendar("date", c) |
CalendarView(c, ui.IsFocused("date")) |
ui.FileBrowser("files", b) |
FileBrowserView(b, ui.IsFocused("files")) |
ui.Form("form", f) |
FormView(f, ui.IsFocused("form")) |
ui.Input("query", v, cursor, "type…") |
Input(v, cursor, "type…", ui.IsFocused("query")) |
ui.Dropdown("dd", d) |
DropdownView(d, ui.IsFocused("dd")) |
For widgets without a dedicated method (PaletteView[T] because it's
generic), fall back to ui.IsFocused(pane) and call the bare
constructor:
val pal = PaletteView[AppMsg](m.Palette, ui.IsFocused("palette"))
// Update side — arrows route to the focused pane.
func arrowDown(m AppModel) AppModel =
m.Focus.Route[AppModel](ArrayOf[state.FocusedCase[AppModel]](
state.OnPane[AppModel]("sidebar", () => moveSidebar(m, +1)),
state.OnPane[AppModel]("table", () => moveTable(m, +1)),
), m)
// View side — every interactive widget reflects current focus.
func view(m AppModel) Widget {
val ui = NewFocusBuilder(m.Focus)
return Row(ArrayOf[LayoutChild](
Fixed(20, ui.SelectListOf("sidebar", m.NavItems, m.NavSel)),
Flex(1, ui.DataTable("table", m.Table)),
))
}
That's the entire keyboard-and-visual focus contract: m.Focus.Route(...)
in update, NewFocusBuilder(m.Focus) + a method per widget in view.
Most apps want every row of a list-shaped widget to be clickable — "click row 3 = navigate to row 3 + Enter on it". The framework bakes this contract into each interactive widget's click-aware sibling constructor:
val nav = ui.SelectListOfPick[Msg](
"sidebar", labels, sel,
(i) => OnRowClicked(Idx = i),
)
SelectListOfPick (and its siblings SelectListPick,
DataTableViewClick, TreeFocusedClick, MenuViewClick,
TabsClick, ButtonClick, DropdownViewClick) register each row's
actual rendered rectangle with the runtime's HitRegistry. On a
left-click the runtime queries the registry first and dispatches
the matching row's payload before the user's inputToMsg ever runs.
Pair it with the "click = select + Enter" pattern from the demo:
case OnRowClicked(idx) =>
(jumpToRow(m.Copy(Sel = idx)), NoCmd[Msg]()) // select first, then jump
Visibility is automatic: widgets that aren't part of the current view tree don't register hits. Clicking the y-coordinates where a sidebar would render — while a different screen is active — is inert by construction. No manual gating required.
For widgets the framework doesn't ship (or for click contracts the
built-in *Click siblings don't cover), attach a click directly with
the fluent .OnClick / .OnPickRow methods — see the next section
for the full author-your-own-widget recipe.
The framework's interactive widgets — Button, SelectList, Tabs,
DataTableView, MenuView, Tree, Dropdown — each have a
click-aware sibling (ButtonClick, SelectListPick, TabsClick,
DataTableViewClick, MenuViewClick, TreeFocusedClick,
DropdownViewClick) that bakes the click contract into the
constructor. Same pattern works for widgets you author yourself:
take a typed callback as a parameter and attach it inside the widget
with .OnClick(msg) (whole-area click) or .OnPickRow(count, fn)
(per-row click). Callers compose your widget like a built-in.
Example — a "rating" widget that exposes a single OnSet(score)
event. Each star is a clickable cell; clicking the third star
dispatches OnSet(3).
struct Rating(Score int, Max int)
func RatingView[T any](
r Rating,
OnSet func(int) T, // user defines the event vocab
) Widget {
val cells = arrayRange(1, r.Max + 1).Map((score) => {
val glyph = if (score <= r.Score) "★" else "☆"
// .OnClick attaches the per-star click contract — fluent
// method, type inferred from the message.
return Fixed(3, TextStyled(" " + glyph + " ", DefaultStyle())
.OnClick(OnSet(score)))
})
return Row(cells)
}
Available fluent click attachers:
widget.OnClick(msg)— whole rendered area is a single hitwidget.OnPickRow(count, (i) => msg)— N stacked rows, click row i fires msg(i)widget.OnPickRowH(count, rowH, (i) => msg)— same but with custom row height
Call site:
val stars = RatingView[Msg](
m.Rating,
OnSet = (score) => RateMovie(Score = score),
)
Same pattern for any number of events:
func KanbanView[T any](
board Kanban,
focused bool = false,
OnCardClick func(int, int) T, // (col, row)
OnColumnDrop func(int, int) T, // (fromCol, toCol)
OnAddCard func(int) T,
OnCardDelete func(int, int) T,
) Widget = ...
Each event becomes a constructor parameter, the widget attaches it
to the right inner sub-region via .OnClick / .OnPickRow, and
every call site stays a single non-nested expression.
If your widget can't be expressed by composing existing primitives —
e.g., a pixel-level chart, a custom border, anything that wants to
write directly into the Buffer — use Custom(minW, minH, render).
The render closure receives the rendered area, the Buffer, and
the live HitRegistry, so you can paint anything and register
your own click hits with hits.Register(rect, payload):
func HistogramView[T any](
buckets Array[int],
OnBarClick func(int) T,
) Widget = Custom(40, 8, (area, buf, hits) => {
val barWidth = area.Width / buckets.Length()
var i = 0
val n = buckets.Length()
for i < n {
val barRect = Rect(
X = area.X + i * barWidth,
Y = area.Y,
Width = barWidth,
Height = area.Height,
)
drawBar(buckets.Get(i), barRect, buf)
if hits != nil {
hits.Register(barRect, OnBarClick(i))
}
i = i + 1
}
})
Same payload-erasure rule as the built-ins: the OnBarClick(i) value
is type T, the registry stores it as any, the runtime restores
the type when it dispatches the click. You never see any at the
call site.