# Widgets and Protocols
Deft's TUI uses a DOM-style widget model. Every widget is a tagged
map implementing one or more protocols. The framework queries
behaviour via protocol dispatch — never type names — so widgets stay
small and composable.
Build interfaces with [`deft/ui/compose`](/platform/compose): construct
components with `--flags`, compose with `|> [child …]` /
`|> [children …]`. The examples below use that style; the tagged-map
form is the underlying representation and is only manipulated directly
for state patching and data-driven props.
## The Protocol Catalogue
### Panel-level (Zig-registered at startup)
The framework expects every panel's root app to implement `Panel`
and (optionally) `AppLifecycle`. These protocols are registered by
the Zig runtime, not via `defprotocol`.
| Protocol | Methods | Used by |
|---|---|---|
| `Panel` | `draw {ctx}`, `on-event {event}`, `layout {bounds}`, `children {}` | Required for any panel app |
| `AppLifecycle` | `on-init {}`, `on-suspend {}`, `on-resume {}`, `on-deinit {}` | Optional lifecycle hooks |
| `Focusable` (panel) | `on-focus {}`, `on-blur {}`, `accepts-focus? {}` | Panel-level focus |
| `Scrollable` (panel) | `on-scroll {delta}`, `scroll-offset {}` | Panel-level scroll |
### Widget-level (declared in `packages/ui/src/protocols.dft`)
These are declared via `defprotocol` and live in the `ui` package.
Import `ui/protocols` to load the specs.
| Protocol | Methods | Used for |
|---|---|---|
| `Drawable` | `draw {ctx bounds}` | Render |
| `Measurable` | `measure {}`, `natural-height {}` | Layout queries |
| `Container` | `children {}` | Holds children |
| `Keyable` | `on-key {event bounds}` | Keyboard input |
| `Hittable` | `hit {event bounds}` | Mouse hit testing |
| `Textable` | `on-text-input {event bounds}` | Character composition |
| `Editable` | `get-text {}`, `set-text {new-text}` | Editable text content |
| `Selectable` | `selected {}`, `items {}`, `select {index}` | Selection UIs |
| `Focusable` (widget) | `accepts-focus? {}`, `on-focus {}`, `on-blur {}` | Widget focus slot |
| `Scrollable` (widget) | `content-height {}`, `scroll-offset {}`, `on-scroll {delta bounds}` | Inner scroll |
The widget-level `Focusable`/`Scrollable` coexist with the
panel-level ones because they live at different layers: panels own
the keyboard focus slot, widgets participate in tab-cycling within
a panel.
## Defining a Custom Widget
Most interfaces are built from the components in
the widget library via
UI Composition — no type or protocol code needed.
Write a custom widget only when the library has no component for the
behaviour you need: declare a `deftype` extending the base widget type,
then implement each protocol as a separate `defimpl` block. This is the
structural/protocol layer the compose helpers build on.
import "deft/ui/protocols" as _ # load protocol specs import "deft/ui/components/widget-base" as widget-base deftype CounterWidget << UIWidget %{ :count %{:type :number :default 0} # inherits :id, :tab-index, etc. } # Constructor def make-counter {|| %CounterWidget{ :id "counter" :count 0 } } # Render — implements Drawable defimpl CounterWidget Drawable { draw {|ctx bounds| @doc "Draw the counter value centered in bounds." draw/fill-bg $ctx %{ :r 20 :g 20 :b 30 } draw/text $ctx $bounds~x $bounds~y "Count: $self~count" | } } # Keyboard — implements Keyable defimpl CounterWidget Keyable { on-key {|event bounds| match $event~key { "+" => [assoc $self :count ($self~count + 1)] "-" => [assoc $self :count ($self~count - 1)] "return" => [assoc $self :count 0] _ => nil # not handled — bubble | | } } # Make it focusable defimpl CounterWidget Focusable { accepts-focus? {|| true } on-focus {|| nil } on-blur {|| nil } }
`$self` inside an impl body refers to the receiver. Most methods
return either `nil` (event not handled — bubble up) or an updated
copy of `$self` (event handled immutably).
## Existing Widget Library
The `ui` package ships a set of ready-to-use widgets under
`packages/ui/src/components/`:
| Component | Purpose |
|---|---|
| `text-input` | Single-line editable input |
| `multiline-text` | Multi-line editable text area |
| `label` | Static text |
| `button` | Clickable button |
| `checkbox` | Boolean toggle |
| `radio-group` | Single-choice option set |
| `dropdown` | Drop-down selector (optional built-in search) |
| `select-list` | Scrollable list with selection |
| `tree` | Expandable hierarchy (file trees, outlines) |
| `grid` | Scrollable tabular data |
| `tab` | Tabbed container |
| `collapsible` | Titled section with a show/hide button; body slides open |
| `column` / `row` | Linear containers |
| `scrollable-panel` | Wraps content with scroll |
| `markdown-viewer` | Renders markdown |
| `form` | Multi-field input forms |
| `field` | Labeled field wrapper |
Use them by constructing instances with the
compose API — flags for props, pipelines to
compose:
from "deft/ui/compose" import child, children from "deft/ui/components/column" import column from "deft/ui/components/row" import row from "deft/ui/components/text-input" import text-input from "deft/ui/components/button" import button def make-search-bar {|| set field [text-input --id "query" --placeholder "Search..." --flex 1] set go [button --text "Go" --on-click on-go] [column --direction :vertical] |> [children @{ [row --gap 1 --min-height 1 --flex 0] |> [children @{ $field $go }] }] }
The trailing-map form (`[text-input %{ :id "query" … }]`) is the same
constructor with a batch argument and remains valid — useful when props
are computed as a map. Nested-map construction is best avoided in app
code; use `child`/`children`.
## Collapsible Sections
`[collapsible --…]` builds a titled section with a `▸`/`▾` chevron and an
optional right-aligned Show/Hide button. The header toggles itself (like
`checkbox`) — no app callback is required — and the body slides open over
`duration` seconds, pushing the rows below it down; hiding releases them.
[collapsible --id "adv" --title "Advanced options" --expanded $state~open --body-h 3 # expanded body height in rows --on-toggle on-section-toggle --children @{ [label --text "…"] [text-input --id "adv-name" --placeholder "name"] }]
The slide is driven by the panel's paced `on-tick`: add one line and the
section animates; `tick` returns false once settled, so an idle panel
stays clean.
import "deft/ui/components/collapsible" as cl defimpl MyPanel Panel { on-tick {| dt| [cl/tick $widget-state $dt] } # true while sliding }
Notes:
- Put the section in a vertical container (the constructor sets
`flex 0`) so siblings below actually reflow.
- `body-h` defaults to the sum of the body's direct children's
`min-height`; pass it explicitly for rich bodies.
- The section is a `:clip true` container: while the body is partially
revealed it is clipped to the animated height, so it neither overdraws
nor steals clicks from the rows below. `:clip true` is a general
container field, not collapsible-specific.
## Theme-Aware Colour Fallback
`text-input`, `select-list`, and `dropdown` (header + list) all default
their colour props (`fg`, `bg`, `sel-fg`, `sel-bg`, `border-fg`) to
`nil`. When a colour is `nil`, the widget's `draw` method reads
`[draw/theme $ctx]` and uses the corresponding workspace theme field
as a fallback:
| Prop | Theme fallback |
|---|---|
| `fg` | `theme~fg` (or `theme~accent` for the dropdown header) |
| `bg` | `theme~bg` (or `theme~border` for inputs / dropdown header) |
| `sel-fg` | `theme~bg` |
| `sel-bg` | `theme~accent` |
| `border-fg` | `theme~border` |
Practical implication: omit the colour props entirely and the widget
tracks `Ctrl+b t` theme swaps automatically. Pass explicit colours only
when you want a fixed look (e.g. the showcase demo's accent-styled
dropdown).
# Theme-tracked — no colours passed [dropdown --id "topic" --items $titles --selected $idx --open $open --on-toggle on-toggle --on-change on-change] # Fixed-look — colours override the theme [dropdown --id "topic" --items $titles --selected $idx --open $open --fg "#ffffff" --bg "#2a2a3a" --sel-bg "#3366cc"]
## Dropdown
`[dropdown --…]` composes a Focusable header with a floating overlay
list. State is app-owned: the app holds `open` (and, in filterable mode,
`query`) and rebuilds on toggle/change.
[dropdown --id "lang" --items @{ "Red" "Green" "Blue" } --selected 0 --open false --on-toggle {|| ... } # flip `open`, rebuild --on-change {| idx| ... } # commit: act on items[idx] --list-width 40]
The header always renders a leading `▾` affordance (so the row never
reads as plain text) plus a trailing `▼` / `▲` state arrow.
### Filterable mode
Add `--filterable true`, pass a `query` string + `on-query` callback, and
the overlay grows a Focusable search input above the list. Filtering is
internal — `items` stays the FULL list, `selected` stays the FULL
index, and `on-change` always receives the full-list index:
[dropdown --id "man" --items $_titles --selected $state~idx --open $state~open --filterable true --query $state~q --placeholder "Filter…" --on-query {| q| ... } # store $state~q, rebuild --on-toggle {|| ... } --on-change {| idx| ... }]
Typical panel-side handlers:
def on-toggle {|| set next-open (!$state~open) def state [merge $state %{ :open $next-open :query "" }] [rebuild-widget-tree] if $next-open { [tui/focus-widget "man-search"] } # focus the field } def on-query {| q | def state [assoc $state :query $q] [rebuild-widget-tree] [tui/focus-widget "man-search"] # keep focus across rebuild } def on-change {| idx | def state [merge $state %{ :selected $idx :open false :query "" }] [rebuild-widget-tree] }
The search input's id is derived from the dropdown id as
`"<id>-search"`, and the inner select-list as `"<id>-list"`.
### Panel-level keyboard navigation
`text-input` only handles left/right/typing/Enter-with-handler — it
returns `nil` for arrow keys and bare `Enter`, which bubble to the
panel. Route those to the dropdown's list via
`dropdown/nav-key`:
import "deft/ui/components/dropdown" as dd defimpl MyApp Keyable { on-key {| event | if ($event~type != :key-down) { return nil } if ($event~key == :escape) { [close-dropdown]; return $self } if $state~dropdown-open { set tree [deref $widget-state] set list [find-by-id $tree "my-dd-list"] if ![nil? $list] { set result [dd/nav-key $list $event~key] if [nth $result 0] { set new-list [nth $result 1] # Enter returns nil — on-change already rebuilt. if ![nil? $new-list] { set tree [replace-by-id $tree "my-dd-list" $new-list] [reset! $widget-state $tree] } return $self } } } nil } }
`nav-key` returns `@{handled new-list-or-nil}`:
| Return | Meaning |
|---|---|
| `@{false nil}` | not a nav key; let the event bubble |
| `@{true $new-list}` | movement key (up/down/pgup/pgdn/home/end); write back via `replace-by-id` |
| `@{true nil}` | `enter` — the list's `on-change` fired (which translates filtered→full index and calls the app's on-change); tree is stale, skip mutation |
`nav-key` writes both `:selected` and `:scroll-y`, keeping the
highlight visible. The dropdown's list runs with `follow-selected`
false` (see below), so the mouse wheel can scroll the viewport freely`
without `draw` yanking it back to the highlight.
## Select-List
`[select-list --…]` is a single-selection scrollable list. Beyond
the obvious `items` / `selected` / `on-change` / `on-key` props, two
matter in practice:
- `follow-selected` (default `true`) — when true, `draw` auto-clamps
`scroll-y` so the selected row is always visible. Set `false` when
you want mouse-wheel scrolling to roam freely (the dropdown does
this; its `nav-key` keeps the highlight visible itself).
- Colour props default to `nil` — falls back to the workspace
theme. See Theme-Aware Colour Fallback.
The `on-key` delegate fires on every up/down/enter/click with
`(key, new-sel)` — useful for syncing panel state with the highlight
even when the user is only browsing.
[select-list --items $items --selected $idx --on-key {| key idx| ... } # fires on up/down/enter/click --on-change {| idx| ... } # fires on enter / click --follow-selected true]
## Tree
`[tree --…]` renders a nested hierarchy as indented rows with
expand/collapse markers and a keyboard cursor. Nodes are plain maps (or
bare strings for leaves) — no custom type needed:
%{ :id "src" # stable id for expansion/cursor state (recommended;
# falls back to a positional path like "0.2")
:label "src" # display text (required for map nodes)
:children @{ ... } # presence makes the node expandable
:icon "▸" # optional glyph before the label
:fg "#..." :bg "#..." } # optional per-node colours
| Prop | Meaning |
|---|---|
| `nodes` | Root node list |
| `expanded` | Map of node id → `true` (seed; the widget updates it) |
| `cursor` | Visible-row index (seed; the widget updates it) |
| `indent` | Cells per depth level (default `2`) |
| `marker-open` / `marker-closed` / `marker-leaf` | Disclosure glyphs |
| `on-select` | `{| info | ...}` on Enter / row click |
| `on-toggle` | `{| id open? | ...}` when a node expands/collapses |
| `on-cursor` | `{| info | ...}` when the cursor moves |
| `fg` / `bg` / `sel-fg` / `sel-bg` / `marker-fg` | Colours (nil → theme) |
| `focusable` | Opt into the keyboard focus slot (default `true`) |
The `info` map passed to `on-select`/`on-cursor` is
`%{ :id :label :depth :index :path :node :has-children :open }` (`:path`
is the id vector root → node).
| Input | Action |
|---|---|
| `↑` / `↓` (or `k` / `j`) | Move the cursor through visible rows |
| `→` (or `l`) | Expand the row, else descend to its first child |
| `←` (or `h`) | Collapse the row, else jump to its parent |
| `Enter` | `on-select` |
| `Space` | Toggle expansion of the cursor row |
| `Home` / `End` / `PgUp` / `PgDn` | Jump / page the cursor |
| Click marker | Toggle expansion (cursor unchanged) |
| Click row | Move the cursor + `on-select` |
| Wheel | Scroll the viewport (cursor stays put) |
### Example: a filesystem hierarchy
import "deft/ui/components/tree" as tree-kit def dir-nodes {| path | @doc "Map a directory listing to tree nodes; dirs recurse eagerly." set out @{} for e [unwrap [fs/list $path]] { set p [fs/join $path $e~name] if $e~is-dir { $out << %{ :id $p :label $e~name :children [dir-nodes $p] } } else { $out << %{ :id $p :label $e~name } } } $out } [tree-kit/tree --nodes [dir-nodes "."] --expanded %{ "src" true } --on-select {| info| ... } --on-toggle {| id open?| ... }]
For lazy loading, give a childless node `:expandable true`: the marker
shows, `on-toggle` fires on expand, and the app adds `:children` in its
rebuild.
### State
`expanded` and `cursor` follow the select-list model: the app seeds them,
the widget mutates them through the event write-back, and the optional
callbacks mirror them into app state. Expansion is app-owned across
rebuilds — the `expanded` prop always wins, so an app that rebuilds the
tree must pass its current expansion (`on-toggle` receives the new value).
`preserve-state` carries `:cursor` by widget `:id`; it never overrides
`:expanded`:
def on-tree-toggle {| id open? | set exp [if $open? {[assoc $expanded $id true]} {[dissoc $expanded $id]}] def state [assoc $state :expanded $exp] [rebuild-widget-tree] }
## The Widget Tree
A panel's content is a tree of widgets. Container widgets
(`column`, `row`, `tab`, `grid`, custom containers) report their
children via the `Container` protocol's `children` method.
defimpl MyColumn Container { children {|| $self~children | } }
The framework walks the tree on draw, on key events (target +
bubble), and on mouse events (hit testing).
## Focus
Each panel owns one widget focus slot — a widget `:id` string on
its Zig `LeafData`. The focused widget receives keyboard events
exclusively; siblings never see them.
tui/focus-widget "query" # set focus tui/focused-widget-id # => "query" (or nil) tui/cycle-focus :forward $tree # Tab-style cycling tui/clear-focus # drop focus
To make a widget focusable:
1. Declare `:id` on it (inherited from `UIWidget`).
2. Implement `Focusable` (`accepts-focus? {|| true}`).
3. Set focus via a stdlib function when the widget becomes the active
editing surface.
The framework cycles focus among `Focusable` widgets (in `tab-index`
order) as the default action when nothing consumes a Tab keypress.
### Display focus (cursor rendering)
A widget's `draw` method reads `$focused` — a framework-injected
boolean local — to decide whether to draw its cursor. Declare it on
the draw signature:
defimpl MyInput Drawable { draw {|ctx bounds focused| # ... draw the cursor if $focused is true ... | } }
Do not read `$self~focused` for this — that field is legacy and
going away.
## The `Gridable` Pattern
Some widgets are designed to host arbitrary user data. UIGrid, for
example, declares its own protocol so the user plugs data in
without subclassing:
defprotocol Gridable %{ row-count {} col-count {} cell {row col value opts^CellOpts} } defimpl MyData Gridable { row-count {|| $self~rows(len) } col-count {|| $self~cols(len) } cell {|row col value opts| # return a display value for (row, col) | } }
Then the grid queries your data via the protocol. The same pattern
works for any "data source" widget.
## Protocol Method Validation
`defprotocol` registers the method specs in the type registry.
`defimpl` methods are validated against those specs at load time —
a method that isn't part of the protocol (almost always a typo, e.g.
`onkey` vs `on-key`) logs a warning rather than silently creating an
unreachable method.
defimpl MyWidget Keyable { on-key {|event bounds| ... } # ✓ matches spec onkey {|event bounds| ... } # ⚠ warned — not in spec }
Dispatch itself remains name-based; specs are for validation and
`satisfies?`.
## Composing Protocols
A typical widget implements `Drawable`, `Keyable` or `Hittable`
depending on input mode, `Focusable` if it can take focus, and
`Editable`/`Selectable`/`Textable` as appropriate.
You don't need to implement every protocol — just the ones your
widget needs. The framework gracefully handles widgets that don't
implement, say, `Scrollable` (it just doesn't scroll).
## Real-World Reference
The cleanest production examples are:
- `packages/ui/src/components/text-input.dft` — minimal focusable
text input, theme-aware colour fallback
- `packages/ui/src/components/dropdown.dft` — composed widget
(Focusable header + overlay select-list + optional search input),
internal index translation for filterable mode, panel-level
`nav-key` helper for keyboard navigation
- `packages/ui/src/components/grid.dft` — complex scrollable grid
with `Gridable` protocol
- `packages/ui/src/components/tree.dft` — expandable hierarchy with
cursor/expansion write-back and shared flatten/scroll helpers
- `packages/ui/src/components/collapsible.dft` — self-toggling section
with a clipped, on-tick-animated slide
- `packages/ui/src/components/multiline-text.dft` — multiline
editor with internal state
Read those when designing a new widget.
## Quick Reference
| Form | Purpose |
|---|---|
| `[label --text "hi"]` | Construct a component with flags |
| `w \|> [child $c]` / `[children @{…}]` | Compose children (UI Composition) |
| `w \|> [props --gap 1]` | Batch typed props |
| `deftype MyWidget << UIWidget %{ ... }` | Define widget type |
| `defimpl MyWidget Drawable { draw {...} }` | Render |
| `defimpl MyWidget Keyable { on-key {...} }` | Keyboard input |
| `defimpl MyWidget Hittable { hit {...} }` | Mouse hit |
| `defimpl MyWidget Focusable { accepts-focus? {||true} ... }` | Opt into focus |
| `defimpl MyWidget Container { children {...} }` | Hold children |
| `[tui/focus-widget "id"]` | Set focus slot |
| `[satisfies? $val "Drawable"]` | Check protocol membership |
## See Also
- UI Composition — the interface construction/composition API
- Event Dispatch — focus, target+bubble, keys
- Dialogs — themed input/menu/file dialogs
- Graphics — pixel-level drawing