# 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