Skip to main content

Agent Reference

This page is the authoritative quick-reference for building TUI applications with oat-latte. It covers every package, type, and pattern in one place — useful for AI coding agents and developers who want the full picture without navigating multiple pages.


Module path

github.com/antoniocali/oat-latte

Sub-packages:

Import pathContents
github.com/antoniocali/oat-latteCore interfaces, Canvas, Buffer, FocusManager, geometry types
github.com/antoniocali/oat-latte/latteStyle, Color, BorderStyle, Theme, built-in themes
github.com/antoniocali/oat-latte/layoutVBox, HBox, Grid, Stack, Border, Padding, VFill, HFill
github.com/antoniocali/oat-latte/widgetText, Title, Button, CheckBox, EditText, List, Label, ProgressBar, StatusBar, NotificationManager, Dialog

Core concepts

Component

Every element in an oat-latte UI implements Component:

type Component interface {
Measure(c Constraint) Size
Render(buf *Buffer, region Region)
}

The render pipeline is a strict two-pass system:

  1. Measure — the parent asks the child for its desired size given a Constraint (available MaxWidth/MaxHeight, -1 means unconstrained).
  2. Render — the parent hands the child its allocated Region and the child draws into a Buffer.

Never skip Measure before Render. Never store the Buffer or Region between frames.

Layout

A Component that holds children implements Layout:

type Layout interface {
Component
Children() []Component
AddChild(child Component)
}

The framework's tree walkers (theme propagation, focus collection, ID lookup) rely on Layout.Children(). Custom container types must implement it.

Focusable

Interactive components implement Focusable:

type Focusable interface {
Component
SetFocused(focused bool)
IsFocused() bool
HandleKey(ev *KeyEvent) bool // return true = event consumed
}

Embed oat.FocusBehavior to get SetFocused/IsFocused for free.

HandleKey must return true if it consumed the event. Returning false tells the canvas to try the next handler (focus cycling, global shortcuts).

BaseComponent

Embed in every custom component:

type MyWidget struct {
oat.BaseComponent // provides ID, Style, FocusStyle, Title, EnsureID(), EffectiveStyle()
oat.FocusBehavior // provides SetFocused(), IsFocused()
}

Call e.EnsureID() in the constructor to auto-assign a unique ID.

EffectiveStyle(focused bool) merges FocusStyle over Style when focused — use this in Render.

Geometry

Size{Width, Height int}                   // desired or allocated size in cells
Region{X, Y, Width, Height int} // rectangle on screen
Constraint{MaxWidth, MaxHeight int} // -1 = unconstrained
Insets{Top, Right, Bottom, Left int} // padding / margin

Style system (latte)

Style struct

type Style struct {
FG, BG Color
Bold, Italic, Underline, Blink, Reverse bool
Padding, Margin Insets
Border BorderStyle
BorderFG, BorderBG Color
TextAlign Alignment
}

Zero value means "inherit / use default". Non-zero fields override. Construct inline or with fluent builder methods (WithFG, WithBG, WithBorder, WithPadding, etc.).

Style.Merge

merged := base.Merge(override)

Non-zero fields from override replace those in base. Use this pattern in ApplyTheme to let theme act as base while preserving caller-set fields:

func (w *MyWidget) ApplyTheme(t latte.Theme) {
w.Style = t.Input.Merge(w.Style) // theme is base; caller wins
w.FocusStyle = t.InputFocus.Merge(w.FocusStyle)
}

Never do w.Style = t.Input — that overwrites explicit overrides such as BorderExplicitNone.

Border sentinels

ConstantValueMeaning
latte.BorderNone0Unset; inherits from theme
latte.BorderExplicitNone-1Actively suppress border (no box drawn)
latte.BorderSingle1┌─┐│└─┘
latte.BorderRounded2╭─╮│╰─╯
latte.BorderDouble3╔═╗║╚═╝
latte.BorderThick4┏━┓┃┗━┛
latte.BorderDashed5┌╌┐╎└╌┘

Check for borders in Render:

if style.Border != latte.BorderNone && style.Border != latte.BorderExplicitNone {
sub.DrawBorderTitle(style.Border, e.Title, latte.Style{}, style)
}

Colors

latte.RGB(255, 200, 100)   // true-color
latte.Hex("#FF6600") // true-color from hex string
latte.ColorRed // ANSI-16
latte.ColorBrightCyan // ANSI-16

Themes

Five built-in themes, all applied via oat.WithTheme(t) at construction time or switched at runtime via app.SetTheme(t):

latte.ThemeDefault   // ANSI-16, works everywhere
latte.ThemeDark // true-color, deep navy/blue-cyan
latte.ThemeLight // true-color, warm off-white
latte.ThemeDracula // true-color, Dracula palette
latte.ThemeNord // true-color, Nord arctic palette

Theme tokens (fields on latte.Theme): Canvas, Text, Muted, Accent, Success, Warning, Error, Panel, PanelTitle, Input, InputFocus, ListSelected, Button, ButtonFocus, CheckBox, CheckBoxFocus, Header, Footer, FocusBorder, Dialog, DialogTitle, Scrim, Tag, NotificationInfo, NotificationSuccess, NotificationWarning, NotificationError, Title.


Canvas

Construction

app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithHeader(headerComponent),
oat.WithBody(bodyComponent),
oat.WithAutoStatusBar(statusBar), // auto-populates footer with key hints
oat.WithPrimary(firstFocusable), // override DFS-first focus
oat.WithGlobalKeyBinding( // app-wide shortcut (see below)
oat.KeyBinding{
Key: tcell.KeyCtrlT, Label: "^T", Description: "Toggle theme",
Handler: func() { /* ... */ },
},
),
)
if err := app.Run(); err != nil {
log.Fatal(err)
}

Key methods

app.Run() error                        // start event loop; blocks until quit
app.Quit() // signal graceful exit
app.SetTheme(t latte.Theme) // replace active theme and re-apply to full tree
app.ShowDialog(d Component) // push modal overlay, steal focus; dismissed by Esc
app.ShowPersistentOverlay(d Component) // render on top always; never dismissed by Esc
app.HideDialog() // pop topmost overlay, restore body focus
app.HasOverlay() bool // true while any dialog is visible
app.FocusByRef(target Focusable) // jump focus to a specific widget
app.GetWidgetByID(id string) Component
app.GetValue(id string) (interface{}, bool)
app.InvalidateLayout() // force full focus re-collection (after tree mutation)
app.NotifyChannel() chan<- time.Time // send to trigger a re-render from a goroutine

Layout regions

The canvas divides the screen vertically: header → body → footer. Header and footer heights are measured each frame; body fills the remainder.


Layout containers

VBox / HBox

vbox := layout.NewVBox()
vbox.AddChild(widget.NewText("Label"))
vbox.AddFlexChild(editText, 1) // flex weight 1 = share remaining space
vbox.AddChild(layout.NewVFill()) // spacer; equivalent to AddFlexChild weight 1
vbox.AddChild(layout.NewVFill().WithMaxSize(1)) // fixed 1-row gap

hbox := layout.NewHBox(child1, child2) // variadic shorthand
hbox.AddFlexChild(progressBar, 1)

Border

panel := layout.NewBorder(innerComponent).
WithTitle("My Panel").
WithTitleStyle(latte.Style{Bold: true})

// Custom style (e.g. explicit padding):
panel := layout.NewBorder(innerComponent).
WithStyle(latte.Style{Padding: latte.Insets{Bottom: 1}}).
WithTitle("My Panel")

Border automatically sets its border color to t.FocusBorder when any descendant is focused (after theme application).

Padding

padded := layout.NewPaddingUniform(child, 1)          // 1 cell all sides
padded := layout.NewPadding(child, latte.Insets{Top: 1, Left: 2})

Dialog

dlg := widget.NewDialog("Title").
WithChild(bodyComponent).
WithSize(widget.DialogPercent(50), widget.DialogPercent(60)) // 50% × 60% of terminal

// OR fixed size:
dlg := widget.NewDialog("Confirm").
WithChild(bodyComponent).
WithMaxSize(52, 9) // exactly 52 × 9 cells

app.ShowDialog(dlg)
// inside a button callback:
app.HideDialog()

Dialog always centres itself and paints a full-screen scrim behind it.

Grid

g := layout.NewGrid(2, 3)          // 2 rows, 3 cols
g.AddChildAt(widget, 0, 0, 1, 1) // row, col, rowSpan, colSpan
g.WithGap(0, 1) // rowGap, colGap

Widgets

Text

t := widget.NewText("Hello, world!")
t.SetText("Updated text")
t.GetText() string

Supports word-wrap (bounded by render width) and vertical scroll (Scrollable).

Button

btn := widget.NewButton("Save", func() {
// pressed
}).WithID("save-btn")

Activated by Enter or Space.

CheckBox

cb := widget.NewCheckBox("Enable feature").
WithOnToggle(func(checked bool) { /* ... */ })
cb.IsChecked() bool
cb.SetChecked(true)

EditText

// Single-line
input := widget.NewEditText().
WithID("username").
WithPlaceholder("Enter username…").
WithHint("Username"). // persistent label above the field
WithMaxLength(64).
WithOnChange(func(s string) { /* live */ }).
WithOnSave(func(s string) { /* ^S pressed */ }).
WithOnCancel(func() { /* ^G pressed */ })

// Multi-line
body := widget.NewMultiLineEditText().
WithHint("Description").
WithPlaceholder("Write here…")

// Borderless (hint replaces the border's visual framing)
field := widget.NewEditText().
WithStyle(latte.Style{Border: latte.BorderExplicitNone}).
WithHint("Email")

input.SetText("hello")
input.GetText() string

Built-in key bindings: ^S Save, ^G Cancel, ^K kill-to-EOL, ^U kill-from-SOL, ^A/Home start-of-line, ^E/End end-of-line.

List

items := []widget.ListItem{
{Label: "First item", Value: 1},
{Label: "Second item", Value: 2},
}
list := widget.NewList(items).
WithID("my-list").
WithOnSelect(func(idx int, item widget.ListItem) { /* Enter pressed */ }).
WithOnDelete(func(idx int, item widget.ListItem) { /* Del pressed */ }).
WithOnCursorChange(func(idx int, item widget.ListItem) { /* live preview */ })

list.SetItems(newItems)
list.SelectedItem() widget.ListItem
list.SelectedIndex() int

Label (tag chips)

lbl := widget.NewLabel([]string{"go", "tui"})
lbl.SetLabels(tags)

Renders inline chips separated by ·.

ProgressBar

pb := widget.NewProgressBar().
WithShowPercent(true)
pb.SetValue(0.75) // 0.0 – 1.0

NotificationManager

notifs := widget.NewNotificationManager()
notifs.SetNotifyChannel(app.NotifyChannel())

app.ShowPersistentOverlay(notifs) // mount as persistent overlay (never dismissed by Esc)

notifs.Push("Saved", widget.NotificationKindSuccess, 2*time.Second)
notifs.Push("Error!", widget.NotificationKindError, 0) // 0 = no auto-dismiss

StatusBar

bar := widget.NewStatusBar()
// Pass to canvas:
oat.WithAutoStatusBar(bar)

Auto-populates with the focused component's KeyBindings() plus any registered global bindings.


Focus system

Automatic collection

On Canvas.Run() the framework performs a DFS over the component tree and registers every Focusable node. Order is DFS (depth-first, children left-to-right), which corresponds to visual top-left to bottom-right order.

Cycling

Tab → next focusable. Shift+Tab → previous. Arrow keys cycle if the focused component does not consume them (returns false from HandleKey).

Keyboard dispatch

Tab / Shift+Tab          → FocusManager.Next() / Prev()
Any key → FocusManager.Dispatch(ev)
├─ walk KeyBindings() with Handler != nil → invoke handler (consumed)
└─ else → focused.HandleKey(ev)
├─ true → consumed
└─ false → canvas.dispatchGlobal(ev)
├─ matching global binding found → invoke handler (consumed)
└─ no match → canvas tries Left/Right focus cycling

Global bindings are checked after the focused widget so that widgets can shadow them when needed (e.g. an EditText consuming Esc to cancel editing rather than triggering the app-level quit).

Global key bindings

Register app-wide shortcuts with WithGlobalKeyBinding at construction time:

app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
oat.WithGlobalKeyBinding(
oat.KeyBinding{
Key: tcell.KeyCtrlT,
Label: "^T",
Description: "Toggle theme",
Handler: func() {
current = (current + 1) % len(themes)
app.SetTheme(themes[current])
},
},
oat.KeyBinding{
Key: tcell.KeyCtrlH,
Label: "^H",
Description: "Help",
Handler: func() { app.ShowDialog(helpDialog) },
},
),
)
  • Variadic: pass multiple bindings in one call, or call WithGlobalKeyBinding multiple times — bindings accumulate.
  • Global bindings appear in the status bar alongside the focused widget's own hints.
  • A focused widget can shadow a global binding by returning true from HandleKey for the same key.

Custom Focusable (proxy pattern)

Wrap an existing widget to intercept specific keys without modifying the widget itself:

type myProxy struct {
*widget.List
app *App
}

func (p *myProxy) HandleKey(ev *oat.KeyEvent) bool {
if ev.Key() == tcell.KeyRune && ev.Rune() == 'n' {
p.app.showNewDialog()
return true // consumed
}
return p.List.HandleKey(ev) // delegate the rest
}

func (p *myProxy) KeyBindings() []oat.KeyBinding {
extra := []oat.KeyBinding{
{Key: tcell.KeyRune, Rune: 'n', Label: "n", Description: "New item"},
}
return append(extra, p.List.KeyBindings()...)
}

Programmatic focus

app.FocusByRef(myTitleInput)   // jump to a specific widget

FocusByRef is pointer identity. The target must be in the current focus tree (body or active dialog).

FocusGuard — context-aware Tab cycling

Implement oat.FocusGuard to dynamically exclude a component (and its whole subtree) from Tab cycling:

type FocusGuard interface {
IsFocusable() bool
}

When IsFocusable() returns false, walkFocusable skips the node and all its descendants. This is the correct way to build context-sensitive panels where entire subtrees should be unreachable depending on application state.

Pattern — mode-gated inputs:

// Thin wrapper that only participates in Tab cycling when editorMode is active.
type editorInputGuard struct {
*widget.EditText
app *App
}
func (g *editorInputGuard) IsFocusable() bool { return g.app.editorMode }

// A custom component can implement FocusGuard directly.
func (s *myShim) IsFocusable() bool { return !s.app.editorMode }

Call canvas.InvalidateLayout() whenever the mode changes so the focus tree is rebuilt:

func (a *App) setEditorMode(on bool) {
if a.editorMode == on { return }
a.editorMode = on
a.canvas.InvalidateLayout()
}

After InvalidateLayout(), call canvas.FocusByRef(target) to set the desired initial focus for the new mode.

KeyBinding

type KeyBinding struct {
Key tcell.Key
Rune rune // only used when Key == tcell.KeyRune
Mod tcell.ModMask
Label string // short hint, e.g. "^S"
Description string // e.g. "Save"
Handler func() // nil = display-only hint; non-nil = executed by Dispatch
}

Theming a custom widget

func (w *MyWidget) ApplyTheme(t latte.Theme) {
// Theme as base, caller-set fields take precedence via Merge.
w.Style = t.Input.Merge(w.Style)
w.FocusStyle = t.InputFocus.Merge(w.FocusStyle)
}

Register ApplyTheme on the type (not a pointer receiver) so the framework's tree walker can call it on the embedded value.


Common patterns

Basic two-panel app

list := widget.NewList(items).WithID("list")
detail := widget.NewText("")

list.WithOnCursorChange(func(_ int, item widget.ListItem) {
detail.SetText(fmt.Sprint(item.Value))
})

body := layout.NewHBox()
body.AddFlexChild(layout.NewBorder(list).WithTitle("Items"), 1)
body.AddFlexChild(layout.NewBorder(detail).WithTitle("Detail"), 3)
func showConfirm(app *oat.Canvas, msg string, onConfirm func()) {
cancelBtn := widget.NewButton("Cancel", func() { app.HideDialog() })
okBtn := widget.NewButton("OK", func() { onConfirm(); app.HideDialog() })

btnRow := layout.NewHBox()
btnRow.AddChild(layout.NewHFill())
btnRow.AddChild(cancelBtn)
btnRow.AddChild(layout.NewHFill().WithMaxSize(2))
btnRow.AddChild(okBtn)

body := layout.NewPaddingUniform(layout.NewVBox(
widget.NewText(msg),
layout.NewVFill().WithMaxSize(1),
btnRow,
), 1)

app.ShowDialog(
widget.NewDialog("Confirm").
WithChild(body).
WithMaxSize(50, 9),
)
}

Borderless editor with hints

titleInput := widget.NewEditText().
WithStyle(latte.Style{Border: latte.BorderExplicitNone}).
WithHint("Title").
WithPlaceholder("Untitled…")

bodyInput := widget.NewMultiLineEditText().
WithStyle(latte.Style{Border: latte.BorderExplicitNone}).
WithHint("Body").
WithPlaceholder("Write here…")

editorVBox := layout.NewVBox(titleInput)
editorVBox.AddFlexChild(bodyInput, 1)

panel := layout.NewBorder(editorVBox).WithTitle("Editor")

Notification toasts

notifs := widget.NewNotificationManager()
app := oat.NewCanvas(oat.WithTheme(latte.ThemeDark), oat.WithBody(body))
notifs.SetNotifyChannel(app.NotifyChannel())
app.ShowPersistentOverlay(notifs) // mount permanently (never dismissed by Esc)

// later, from any callback:
notifs.Push("Saved successfully", widget.NotificationKindSuccess, 2*time.Second)

Full application skeleton

type App struct {
canvas *oat.Canvas
notifs *widget.NotificationManager
// ... widgets
}

func (a *App) build() {
statusBar := widget.NewStatusBar()
a.notifs = widget.NewNotificationManager()

// ... build component tree ...

themes := []latte.Theme{latte.ThemeDark, latte.ThemeLight, latte.ThemeDracula, latte.ThemeNord}
themeIdx := 0

a.canvas = oat.NewCanvas(
oat.WithTheme(themes[themeIdx]),
oat.WithHeader(header),
oat.WithBody(body),
oat.WithAutoStatusBar(statusBar),
oat.WithPrimary(primaryFocusable),
oat.WithGlobalKeyBinding(oat.KeyBinding{
Key: tcell.KeyCtrlT,
Label: "^T",
Description: "Toggle theme",
Handler: func() {
themeIdx = (themeIdx + 1) % len(themes)
a.canvas.SetTheme(themes[themeIdx])
},
}),
)

a.notifs.SetNotifyChannel(a.canvas.NotifyChannel())
a.canvas.ShowPersistentOverlay(a.notifs)
}

func main() {
a := &App{}
a.build()
if err := a.canvas.Run(); err != nil {
log.Fatal(err)
}
}

Constraints and invariants

  • Never call Render without having called Measure first in the same pass.
  • Never write to a Buffer outside the Region passed to Render — use buf.Sub(region) to get a clipped sub-buffer and write into that.
  • BorderExplicitNone (-1) actively suppresses a border. Check both BorderNone and BorderExplicitNone in render guards.
  • Style.Merge preserves BorderExplicitNone through the cascade — do not use direct struct assignment in ApplyTheme.
  • Canvas.InvalidateLayout() must be called after any dynamic addition or removal of components from the tree to re-collect focusable nodes.
  • Key event handlers run on the main goroutine. Use app.NotifyChannel() to trigger re-renders from background goroutines.
  • app.ShowPersistentOverlay(notifs) mounts NotificationManager as a persistent (non-modal) overlay. It is never dismissed by Esc and always renders on top of modal dialogs.
  • Global bindings registered with WithGlobalKeyBinding fire after the focused widget. A widget can shadow a global binding by returning true from HandleKey.
  • app.SetTheme(t) re-applies the theme to the entire tree including all overlays and persistent overlays. It resets the canvas background style so the new theme's Canvas token takes effect.