# 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 |