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, named color palette
github.com/antoniocali/oat-latte/layoutVBox, HBox, Grid, Stack, Border, Padding, VFill, HFill, FlexChild, AlignChild, ScrollView, VGap, HGap
github.com/antoniocali/oat-latte/widgetText, Title, Button, CheckBox, EditText, List, ComponentList, Label, ProgressBar, StatusBar, NotificationManager, Dialog, Divider

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

Anchor

oat.Anchor is the horizontal-axis positioning type. It is used by ProgressBar.WithPercentage, Border.WithTitle, and Divider.WithMaxSize (for AxisVertical dividers).

oat.AnchorLeft    // default — left edge
oat.AnchorCenter // centred horizontally
oat.AnchorRight // right edge

VAnchor

oat.VAnchor is the vertical-axis positioning type. It is used by Divider.WithMaxSizeV (for AxisHorizontal dividers).

oat.VAnchorTop     // default — top edge
oat.VAnchorMiddle // centred vertically
oat.VAnchorBottom // bottom edge

The two types are kept separate so APIs that accept horizontal placement cannot accidentally receive a VAnchor value and vice versa — the compiler enforces correct axis usage.

HAlign / VAlign

oat.HAlign and oat.VAlign are cross-axis widget positioning types. They control where a widget sits within its allocated slot in a box layout — as opposed to Anchor/VAnchor which control where a piece of text or a rule sits within its widget.

HAlign is used by VBox (vertical box distributes rows; each child needs horizontal placement):

oat.HAlignFill    // 0 — fill the full allocated width (default, unchanged behaviour)
oat.HAlignLeft // shrink to desired width, pin left
oat.HAlignCenter // shrink to desired width, centre horizontally
oat.HAlignRight // shrink to desired width, pin right

VAlign is used by HBox (horizontal box distributes columns; each child needs vertical placement):

oat.VAlignFill    // 0 — fill the full allocated height (default, unchanged behaviour)
oat.VAlignTop // shrink to desired height, pin top
oat.VAlignMiddle // shrink to desired height, centre vertically
oat.VAlignBottom // shrink to desired height, pin bottom

Every built-in widget exposes fluent WithHAlign / WithVAlign methods that return the concrete widget type, so alignment can be set inline in a builder chain:

btn := widget.NewButton("Save", fn).WithHAlign(oat.HAlignRight)
lbl := widget.NewText("note").WithVAlign(oat.VAlignBottom)

Any component embedding BaseComponent automatically satisfies oat.AlignProvider. Custom widgets that have not yet added their own WithHAlign/WithVAlign builders can fall back to setting the field directly:

myWidget.BaseComponent.HAlign = oat.HAlignRight

Alignment scope — direct children only

HAlign and VAlign are only honoured on the direct children of the box that distributes the relevant axis. The alignment offset is computed once, at the box-to-direct-child boundary. Intermediary containers (Border, Padding, FlexChild, Stack) pass the full allocated region straight to their own child without any alignment computation, so VAlign/HAlign set on a deeply nested widget are silently ignored.

ContainerConsults VAlign on its children?Consults HAlign on its children?
HBoxYes — direct children onlyNo
VBoxNoYes — direct children only
BorderNoNo
PaddingNoNo
FlexChildNoNo
AlignChildProvides override to parent boxProvides override to parent box

Correct pattern — align a bordered box inside an HBox:

// WRONG: VAlign is set on the Text, which is buried inside Border.
// HBox sees Border.GetVAlign() == VAlignFill and ignores the Text's preference.
inner := widget.NewText("mid").WithVAlign(oat.VAlignMiddle) // ← has no effect
border := layout.NewBorder(inner)
hbox.AddFlexChild(border, 1)

// CORRECT option A: set VAlign on the Border directly (it embeds BaseComponent).
inner := widget.NewText("mid")
border := layout.NewBorder(inner)
border.BaseComponent.VAlign = oat.VAlignMiddle // HBox reads this via AlignProvider
hbox.AddFlexChild(border, 1)

// CORRECT option B: wrap with AlignChild (highest-priority override).
inner := widget.NewText("mid")
border := layout.NewBorder(inner)
hbox.AddFlexChild(layout.NewAlignChild(border, oat.HAlignFill, oat.VAlignMiddle), 1)

The same rule applies to HAlign inside a VBox: set it on the VBox's direct child, not on a widget nested deeper inside a container.


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.callerStyle) // theme is base; caller wins
w.FocusStyle = t.InputFocus.Merge(w.callerFocusStyle)
}

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

callerStyle pattern (required for correct theme switching)

Every widget that exposes WithStyle must store the caller's original intent in a separate callerStyle field and use that as the Merge base in ApplyTheme — never w.Style (the current, already-themed value).

The problem with w.Style = t.Input.Merge(w.Style):

  • First ApplyTheme call: w.Style is zero → Merge takes the theme value → correct.
  • Second ApplyTheme call (theme switch): w.Style now holds the old theme's full colours. Merge keeps those stale non-zero fields; the new theme cannot fully replace them. Borders, colours and attributes from the old theme get permanently stuck.

Correct implementation:

type MyWidget struct {
oat.BaseComponent
callerStyle latte.Style // set by WithStyle; never mutated by ApplyTheme
callerFocusStyle latte.Style // set by WithFocusStyle; never mutated by ApplyTheme
}

func (w *MyWidget) WithStyle(s latte.Style) *MyWidget {
w.Style = s // used for immediate rendering before theme is applied
w.callerStyle = s // preserved as the stable Merge base for all future ApplyTheme calls
return w
}

func (w *MyWidget) ApplyTheme(t latte.Theme) {
w.Style = t.Input.Merge(w.callerStyle) // always start from caller's original intent
w.FocusStyle = t.InputFocus.Merge(w.callerFocusStyle)
}

Do not pre-seed FocusStyle in the constructor with hardcoded values (e.g. latte.Focused or latte.Style{BorderFG: latte.ColorBrightCyan}). Those non-zero fields will survive every SetTheme via Merge, blocking the new theme's accent colour from taking effect. Let ApplyTheme set FocusStyle entirely from the theme token.

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, oat.AnchorLeft)
}

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.

Theme builder methods

Every Theme value exposes a With<Token> method for each field. All methods return a new Theme by value — the originals are never mutated. Style-typed methods use Style.Merge internally so only non-zero fields of the supplied Style are applied.

// Nord but with no borders anywhere
borderless := latte.ThemeNord.
WithPanel(latte.Style{Border: latte.BorderExplicitNone}).
WithInput(latte.Style{Border: latte.BorderExplicitNone}).
WithButton(latte.Style{Border: latte.BorderExplicitNone}).
WithDialog(latte.Style{Border: latte.BorderExplicitNone}).
WithName("nord-borderless")

// Dark theme with a custom accent colour
pink := latte.ThemeDark.
WithAccent(latte.Style{FG: latte.Hex("#ff69b4")}).
WithFocusBorder(latte.Hex("#ff69b4")).
WithName("dark-pink")

WithFocusBorder accepts a Color directly (not a Style) because FocusBorder is a plain Color field. All other token methods accept a Style.

WithRoundedCorner(bool) sets the RoundedCorner bool field directly (not a Style). All five built-in themes set RoundedCorner: true, which makes Button and Border automatically use arc corners (╭─╮ / ╰─╯) when the theme is applied. Widgets whose resolved border style is incompatible (e.g. BorderDashed) silently keep square corners.


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.WithNotificationManager(notifs), // wires channel + mounts as persistent overlay
oat.WithGlobalKeyBinding( // app-wide shortcut (see below)
oat.KeyBinding{
Key: tcell.KeyCtrlT, Mod: tcell.ModCtrl, 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.GetTheme() *latte.Theme // return pointer to active theme; nil if none set
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)

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.NewVGap(1)) // fixed 1-row gap (always exactly 1 row)

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

// Wrap a VBox or HBox in a ScrollView with one call:
sv := layout.NewVBox(items...).AsScrollView().WithScrollBar(true)

Cross-axis alignment

VBox.WithHAlign sets a default horizontal alignment for all children that do not declare their own:

// All children right-aligned inside a VBox:
vbox := layout.NewVBox(title, body, footer).WithHAlign(oat.HAlignRight)

HBox.WithVAlign sets a default vertical alignment for all children:

// All children bottom-aligned inside an HBox:
hbox := layout.NewHBox(textA, textB).WithVAlign(oat.VAlignBottom)

Zero value (HAlignFill / VAlignFill) is the default and preserves the previous full-stretch behaviour — no breaking change.

VGap / HGap

Fixed-size inert spacers. Unlike VFill/HFill, they do not participate in flex distribution and always claim exactly n cells.

// Fixed 1-row gap in a VBox — always exactly 1 row regardless of available space.
vbox.AddChild(layout.NewVGap(1))

// Fixed 2-column gap in an HBox — always exactly 2 columns.
hbox.AddChild(layout.NewHGap(2))

Constructors: layout.NewVGap(n int) *VGap, layout.NewHGap(n int) *HGap. n < 0 is clamped to 0. Render is a no-op.

When to use VGap/HGap vs VFill.WithMaxSize/HFill.WithMaxSize:

  • Use VGap/HGap when the gap must always be exactly n cells.
  • Use VFill.WithMaxSize/HFill.WithMaxSize when the gap should shrink gracefully if the container is small (capped flex spacer).

FlexChild

layout.NewFlexChild(child, weight...) wraps any Component as a flex slot so it can be passed inline to variadic constructors:

// Equivalent to: vbox := layout.NewVBox(); vbox.AddChild(title); vbox.AddFlexChild(body, 1); vbox.AddChild(btnRow)
vbox := layout.NewVBox(
title,
layout.NewFlexChild(body), // weight defaults to 1
btnRow,
)
  • Weight defaults to 1; minimum effective weight is 1.
  • Implements oat.Layout via Children() — theme propagation and focus collection recurse into the wrapped component automatically.

AlignChild

layout.NewAlignChild(child oat.Component, h oat.HAlign, v oat.VAlign) *AlignChild wraps any Component with per-child cross-axis alignment overrides. Use this when a single child should deviate from the box-wide default, or when you want inline per-child alignment without calling WithHAlign/WithVAlign on BaseComponent.

// Mixed: per-child alignment via AlignChild wrapper:
vbox := layout.NewVBox(
layout.NewAlignChild(saveBtn, oat.HAlignRight, oat.VAlignFill),
layout.NewAlignChild(cancelBtn, oat.HAlignLeft, oat.VAlignFill),
)

// Combine AlignChild + flex weight:
vbox.AddFlexChild(layout.NewAlignChild(btn, oat.HAlignRight, oat.VAlignFill), 1)
  • AlignChild is NOT a FlexSpacer — it does not claim flex space on its own. Wrap it with AddFlexChild or NewFlexChild when flex behaviour is needed.
  • Implements oat.Layout via Children() — theme propagation and focus collection recurse into the wrapped component automatically.
  • AlignChild.GetHAlign() / GetVAlign() are checked first by the box's resolution logic — they take precedence over both the child's own BaseComponent.HAlign/VAlign and the box-wide default.

Border

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

// Centred title:
panel := layout.NewBorder(innerComponent).
WithTitle("My Panel", oat.AnchorCenter)

// Rounded corners (╭─╮ / ╰─╯) — only valid for BorderSingle:
panel := layout.NewBorder(innerComponent).
WithTitle("My Panel").
WithRoundedCorner(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).

WithTitle anchor

WithTitle accepts an optional oat.Anchor as a second argument:

func (b *Border) WithTitle(title string, anchor ...oat.Anchor) *Border
AnchorResult
oat.AnchorLeft (default)╭─ My Panel ──────────╮
oat.AnchorCenter╭──── My Panel ────────╮
oat.AnchorRight╭────────── My Panel ──╮

Omitting the anchor defaults to oat.AnchorLeft, keeping all existing call sites unchanged.

WithRoundedCorner

func (b *Border) WithRoundedCorner(rounded bool) *Border
  • Stores the rounded-corner intent in an internal field; does not mutate Style.Border.
  • The effective corner shape is resolved at render time: BorderSingleBorderRounded is toggled based on this field; incompatible styles (BorderDouble, BorderThick, BorderDashed) are silently left unchanged in both directions — no panic is raised.
  • Once called, this explicit choice overrides the theme's RoundedCorner setting for this Border. ApplyTheme will not overwrite it on theme switches.
  • Calling WithRoundedCorner(false) explicitly opts out of rounded corners even when theme.RoundedCorner is true.

Padding

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

ScrollView

ScrollView clips a single child to a viewport and lets the user scroll vertically to reveal content that exceeds the visible height.

// Standalone constructor
sv := layout.NewScrollView(myVBox).WithScrollBar(true)

// Convenience builders on VBox / HBox
sv := layout.NewVBox(items...).AsScrollView().WithScrollBar(true)
sv := layout.NewHBox(cols...).AsScrollView() // horizontal content, vertical scroll

// Scroll bar on the left edge
sv := layout.NewVBox(items...).AsScrollView().WithScrollBar(true, oat.AnchorLeft)

WithScrollBar

func (sv *ScrollView) WithScrollBar(show bool, anchor ...oat.Anchor) *ScrollView
  • true — display a single-column scroll bar; false (default) — no bar.
  • oat.AnchorRight (default) — bar on the right edge.
  • oat.AnchorLeft — bar on the left edge.
  • Bar colours: Muted token → track (), Accent token → thumb (). Set by ApplyTheme; can be overridden per-component with WithTrackColor / WithThumbColor.

WithTrackColor / WithThumbColor

func (sv *ScrollView) WithTrackColor(c latte.Color) *ScrollView
func (sv *ScrollView) WithThumbColor(c latte.Color) *ScrollView

Override the scroll bar colours for this specific ScrollView. Pass any latte.Colorlatte.RGB, latte.Hex, or a named palette constant. Overrides survive SetTheme calls; the theme never replaces a value set here. Pass latte.ColorDefault to revert to theme-driven behaviour.

// Accent track, bright thumb
sv := layout.NewVBox(items...).AsScrollView().
WithScrollBar(true).
WithTrackColor(latte.Hex("#444466")).
WithThumbColor(latte.ColorBrightCyan)

Nesting with Border

Prefer Border(ScrollView(VBox(…))) over ScrollView(Border(VBox(…))). In the first pattern the border chrome is fixed and only the VBox content scrolls. In the second pattern the entire Border (including its title row) scrolls — the top border disappears as the user scrolls down.

// CORRECT — fixed border, scrolling content:
panel := layout.NewBorder(
layout.NewVBox(items...).AsScrollView().WithScrollBar(true),
).WithTitle("Items")

// VALID but unusual — entire border scrolls:
panel := layout.NewScrollView(
layout.NewBorder(layout.NewVBox(items...)).WithTitle("Items"),
).WithScrollBar(true)

VFill / FlexChild inside ScrollView

VFill and FlexChild behave differently depending on whether the content overflows the viewport:

  • Content fits (no scrolling) — the full viewport height is passed to the child, so flex children expand normally.
  • Content overflows (scrolling active) — the child is measured unconstrained and flex children collapse to zero height. Avoid VFill / FlexChild inside a ScrollView that is expected to scroll; use fixed-height children instead.

Focus model

ScrollView is always present in the Tab cycle. HandleKey returns false for all scroll keys when content fits the viewport, so arrow keys fall through to inter-widget focus cycling. When content overflows, HandleKey consumes / (±1 row), PgUp/PgDn (±viewport), and Home/End (jump to extremes).

Do not implement FocusGuard on ScrollView — the focus tree is collected once at startup before any Measure/Render pass, so contentH and viewportH are both zero at collection time. A FocusGuard that checks contentH > viewportH would always return false at startup, permanently excluding the widget from Tab cycling.

Scrollable interface

sv.ScrollOffset() int          // current row offset
sv.ContentHeight() int // full unconstrained child height
sv.ScrollTo(off int) // set offset; clamped to [0, contentH-viewportH]

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")

// With rounded border corners (╭─╮ / ╰─╯):
btn := widget.NewButton("OK", fn).
WithStyle(latte.Style{Border: latte.BorderSingle}).
WithRoundedCorner(true)

Activated by Enter or Space.

Border presence is determined solely by b.Style (the unfocused base style). FocusStyle / ButtonFocus carry only colour and attribute overrides (e.g. Reverse: true, BorderFG). This means the button's layout shape — and therefore Measure output — is stable regardless of focus state.

When b.Style has a border set, Measure returns Height: 3 (top border + label row + bottom border) and the border is drawn at render time. Without a border, Measure returns Height: 1 and the label is rendered as "[ label ]".

All built-in themes set Button.Border: BorderSingle and RoundedCorner: true, so buttons automatically render with rounded arc corners. ButtonFocus carries only Reverse: true and an accent BorderFG to highlight the active button.

WithRoundedCorner

func (b *Button) WithRoundedCorner(rounded bool) *Button
  • true — draws border arc corners (╭╮╰╯) instead of square ones (┌┐└┘).
  • false — disables arc corners regardless of the theme's RoundedCorner setting.
  • Once called, this explicit choice overrides the theme's RoundedCorner setting for this button.
  • If the button's resolved border style is incompatible (BorderDouble, BorderThick, BorderDashed) the rounded-corner request is silently ignored — no panic is raised.

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

ComponentList

ComponentList is the component-row counterpart of List. Each row renders an arbitrary Component (any widget or layout) rather than a plain label string. A Value interface{} field on each item lets the caller correlate rows with application data (e.g. a record ID).

Row heights are variable: each row's component is measured to determine how many terminal rows it occupies. Scroll is tracked by item index so the viewport always shows complete rows.

ComponentList implements oat.Layout (Children() / AddChild) so theme propagation and the focus collector recurse into every row component automatically.

// Build items — each row is an HBox with a name, a flex description, and a status tag.
makeRow := func(name, desc, status string, id int) widget.ComponentListItem {
row := layout.NewHBox(
widget.NewText(name),
layout.NewFlexChild(widget.NewText(desc), 1),
widget.NewText(status),
)
return widget.ComponentListItem{Component: row, Value: id}
}

items := []widget.ComponentListItem{
makeRow("Alice", "Backend engineer", "active", 1),
makeRow("Bob", "Frontend engineer", "inactive", 2),
makeRow("Charlie", "DevOps", "active", 3),
}

list := widget.NewComponentList(items).
WithID("people-list").
WithOnSelect(func(idx int, item widget.ComponentListItem) {
id := item.Value.(int)
// navigate to record with this id
}).
WithOnCursorChange(func(idx int, item widget.ComponentListItem) {
// live preview
})

Builder options mirror List exactly:

MethodDescription
WithStyle(s latte.Style)Override the display style (border, colours, padding)
WithID(id string)Set a stable identifier for Canvas.GetValue(id)
WithSelectedStyle(s latte.Style)Override the highlight style for the selected row
WithHighlight(enabled bool)Fill selected row background with selectedStyle (default true)
WithCursor(cursor string)Gutter character next to the selected row (default >)
WithOnSelect(fn func(int, ComponentListItem))Callback fired on Enter
WithOnDelete(fn func(int, ComponentListItem))Callback fired on Delete
WithOnCursorChange(fn func(int, ComponentListItem))Callback fired on every cursor move
list.SetItems(newItems)
list.SelectedItem() (widget.ComponentListItem, bool)
list.SelectedIndex() int

ComponentList also implements oat.ValueGetter: Canvas.GetValue(id) returns the Value field of the currently selected item.

Label (tag chips)

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

// Without background fill (keeps FG colour, strips BG):
lbl := widget.NewLabel(tags).WithHighlight(false)

Renders inline chips separated by ·.

WithHighlight(false) strips the chip background colour while keeping the foreground colour and text attributes. Default is true (chips render with filled background). The highlight setting does not affect the FG colour or bold/italic attributes.

ProgressBar

pb := widget.NewProgressBar().
WithPercentage(true) // show percent at left (default)
pb.SetValue(0.75) // 0.0 – 1.0

WithPercentage(show bool, anchor ...oat.Anchor) controls whether a " XX%" label is rendered and where. The anchor is optional and defaults to oat.AnchorLeft:

AnchorResult
oat.AnchorLeft" 75% ████░░░░" — label at the left
oat.AnchorCenter"████ 75% ░░░░" — label stamped into the middle of the bar
oat.AnchorRight"████░░░░ 75%" — label at the right

WithShowPercent(bool) still works but does not change the anchor. Prefer WithPercentage.

NotificationManager

notifs := widget.NewNotificationManager()

app := oat.NewCanvas(
oat.WithTheme(latte.ThemeDark),
oat.WithBody(body),
oat.WithNotificationManager(notifs), // wires channel + mounts as persistent overlay
)

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.

Divider

// Horizontal rule (─────) — place in a VBox between items
hd := widget.NewHDivider() // full-width, default rune '─'
hd := widget.NewHDivider().WithRune('═') // double rule
hd := widget.NewHDivider().
WithMaxSize(widget.DividerPercent(60), oat.AnchorCenter) // 60% wide, centred

// Vertical rule (│) — place in an HBox between items
vd := widget.NewVDivider() // full-height, default rune '│'
vd := widget.NewVDivider().
WithMaxSizeV(widget.DividerFixed(8), oat.VAnchorMiddle) // 8 cells tall, centred

// Axis-explicit constructor
d := widget.NewDivider(widget.AxisHorizontal)
d := widget.NewDivider(widget.AxisVertical)

DividerSize controls how much of the allocated space is occupied by the visible rule:

ConstructorMeaning
widget.DividerFillspans the full allocated length (default)
widget.DividerFixed(n)exactly n terminal cells
widget.DividerPercent(p)p% of the allocated length (1–100)

Anchor semantics per axis:

  • AxisHorizontalWithMaxSize(size, anchor ...oat.Anchor) — size controls width; oat.Anchor positions the rule horizontally (AnchorLeft / AnchorCenter / AnchorRight).
  • AxisVerticalWithMaxSizeV(size, anchor ...oat.VAnchor) — size controls height; oat.VAnchor positions the rule vertically (VAnchorTop / VAnchorMiddle / VAnchorBottom).

Using the wrong anchor type for the axis is a compile error — the API enforces correct axis usage.

ApplyTheme maps the Muted token onto the divider. Override with WithStyle.


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,
Mod: tcell.ModCtrl,
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

Call app.FocusByRef(target) to jump focus directly to a specific widget by 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) {
// Always merge onto callerStyle (the original caller-set value), not w.Style.
// Using w.Style would accumulate stale colours from the previous theme on
// every SetTheme call. See the callerStyle pattern in the Style.Merge section.
w.Style = t.Input.Merge(w.callerStyle)
w.FocusStyle = t.InputFocus.Merge(w.callerFocusStyle)
}

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.NewHGap(2))
btnRow.AddChild(okBtn)

body := layout.NewPaddingUniform(layout.NewVBox(
widget.NewText(msg),
layout.NewVGap(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),
oat.WithNotificationManager(notifs), // wires channel + mounts as persistent overlay
)

// 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.WithNotificationManager(a.notifs), // wires channel + mounts as persistent overlay
oat.WithGlobalKeyBinding(oat.KeyBinding{
Key: tcell.KeyCtrlT,
Mod: tcell.ModCtrl,
Label: "^T",
Description: "Toggle theme",
Handler: func() {
themeIdx = (themeIdx + 1) % len(themes)
a.canvas.SetTheme(themes[themeIdx])
},
}),
)
}

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. Exception: HBox.Render calls Render on flex children with VAlignFill without a preceding Measure call in the same frame. ScrollView handles this by re-measuring its child unconstrainedly at the start of its own Render to obtain the true contentH.
  • 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.
  • Buffer propagates the canvas background colour (bg) through Sub. Any cell drawn with BG == ColorDefault inherits this colour instead of the terminal default (typically black). Custom widgets do not need to explicitly fill a background unless they want a colour different from the canvas — DrawText with no BG set is always safe and visually correct on any theme.
  • Buffer.Sub separates coordinate translation (originX/originY) from write-guarding (clip). The origin may be negative (e.g. when ScrollView shifts child coordinates upward by the scroll offset); the clip is always the intersection of the requested region with the parent's clip and is never negative. Custom widgets that call buf.Sub(region) are unaffected by this distinction.
  • 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 background goroutines safely by pushing to notifs directly; WithNotificationManager wires the re-render channel automatically.
  • oat.WithNotificationManager(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.