# 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.
## 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 Widget
The standard pattern: declare a `deftype` extending the base widget
type, then implement each protocol as a separate `defimpl` block.
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 |
| `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:
import "deft/ui/components/text-input" as text-input import "deft/ui/components/button" as button import "deft/ui/components/column" as column def make-search-bar {|| column/make %{ :children @{ [text-input/make %{ :id "query" :placeholder "Search..." }] [button/make %{ :label "Go" :on-click { |state| [do-search $state] } }] | |} }
## 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 %{ ... 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
`"-search"`, and the inner select-list as `"-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](#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 [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/multiline-text.dft` — multiline
editor with internal state
Read those when designing a new widget.
## Quick Reference
| Form | Purpose |
|---|---|
| `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 |