# Event Dispatch
Deft's TUI uses a DOM-style **target + bubble** model. Events
identify a target widget via focus (keyboard) or hit-testing
(mouse), then bubble up to the panel for handling. This page
covers how to wire up an app that participates cleanly in dispatch.
## The Dispatch Phases
For each input event:
1. **Target phase** — the framework identifies the widget that
should receive the event:
- **Keyboard events** → the panel's focused widget (an `:id`
stored on its `LeafData`).
- **Mouse events** → the topmost widget whose bounds contain the
pointer (hit-tested via the `Hittable` protocol).
2. **Bubble phase** — if the target didn't consume the event (or
there's no target), the event bubbles to the panel's typed
protocol methods (`on-key`, `hit`, `on-scroll`).
The framework runs the target phase **before** the panel's bubble
handlers, so focused widgets get first claim on keyboard input
without the panel needing to forward manually.
## Keyboard Routing
A focused text-input consumes keystrokes (so single-key app
shortcuts like `s`/`c` don't collide with typing) while still
letting the app handle leftover keys (escape, global shortcuts).
```
User presses 's'
│
▼
┌────────────────────┐
│ Framework │
│ reads focus slot │
└────────────────────┘
│
▼
┌────────────────────┐ consumes? ┌────────────────────┐
│ Focused text-input │ ───────────────► │ Panel's on-key │
│ (Keyable.on-key) │ no │ (bubble handler) │
└────────────────────┘ └────────────────────┘
```
## Two Wiring Styles
### Legacy: `on-event` bubble handler with pre-pass
The panel calls `[tui/dispatch-tree-event $tree $event $bounds]`
**first**, then handles only what the tree didn't consume:
defimpl MyApp Panel { on-event {|event| # Pre-pass: let the widget tree consume first if ![tui/dispatch-tree-event $widget-state $event $bounds] { # Then handle leftover events at the panel level [match $event~key { :escape => [do-cancel] _ => nil }] } } }
### Preferred: fully-anchored (framework target phase)
Register the widget tree once on `on-init` and let the framework
run the target phase. The panel implements typed protocols as pure
bubble handlers — no pre-pass, no `tui/dispatch-tree-event` call:
defimpl MyApp AppLifecycle { on-init {|| [tui/set-widget-tree $widget-state] # opt into framework dispatch # ... rest of init ... } } defimpl MyApp Keyable { on-key {|event| @doc "Fires ONLY when no focused widget consumed the key." [match $event~key { :escape => [do-cancel] _ => nil # bubble further }] # Modifier chords read the boolean fields: if ($event~ctrl and ($event~key == :s)) { [do-save] } } }
The framework maps each event to the right protocol method
(`on-key` for key-down **and** text events, `hit` for mouse
events, `on-scroll` for wheel) via the same routing widgets use.
The panel is anchored identically to widgets. New apps should use
this style — `on-event` is the legacy fallback.
## A Widget Must Consume Or Bubble
A widget's `on-key` (or `hit`) must return `nil` for events it
doesn't act on, so they bubble. Returning a non-nil value consumes
the event:
defimpl MyInput Keyable { on-key {|event bounds| [match $event~key { :backspace => [handle-backspace $self] :enter => [handle-submit $self] _ => nil # bubble everything else }] } }
This is what lets a focused text-input consume typing while still
letting `escape` reach the panel's `on-key`.
See `packages/ui/src/components/grid.dft` for a production example
of this pattern.
## Focus Stdlib functions
The panel owns a single widget focus slot. Read and set it with:
[tui/focus-widget "id"] # set focus [tui/focused-widget-id] # -> id string | nil [tui/cycle-focus :forward $tree] # Tab-style cycling [tui/cycle-focus :back $tree] # Shift-Tab [tui/clear-focus] # drop focus
### When to set focus
- **`on-init`** — set a default focus if your panel opens with an
obvious editing target.
- **On entering an edit mode** — when the user starts editing,
focus the text input.
- **On clicking a Focusable widget** — the framework claims focus
automatically; you don't need to do this manually.
### When to clear focus
- **Escape from edit mode** — return to browse mode.
- **After submitting a form** — release focus back to the panel.
## Tab / Shift-Tab
The framework cycles focus among `Focusable` widgets (in `tab-index`
order) as the default action when nothing consumes a Tab keypress.
Apps that want custom Tab handling consume it earlier.
There is no need to implement Tab manually.
## Mouse Events
Mouse events hit-test by bounds. Clicking a `Focusable` widget
claims focus; clicking a non-focusable one leaves focus unchanged.
A widget's `hit {|event bounds|}` method returns an action value
(keyword, string, or map) if it handled the event, or `nil` to let
the click bubble.
defimpl MyButton Hittable { hit {|event bounds| if ($event~type == :mouse-down) { :clicked # or a map with details } { nil # let it bubble } } }
## Common Patterns
### Default focus on init
defimpl MyApp AppLifecycle { on-init {|| # ... set up widget tree ... [tui/set-widget-tree $widget-state] [tui/focus-widget "query"] # default focus on the search box } }
### Escape cancels edit mode
defimpl MyApp Keyable { on-key {|event| if ($event~key == :escape) { [tui/clear-focus] [reset-to-browse-mode] } } }
### Avoiding single-key collisions with focused inputs
Plain printable characters arrive as **`:text` events** (in the
`:text` field) — named keys (arrows, enter, escape, …) arrive as
`:key-down` with a `:key` keyword. Handle each in its branch:
defimpl MyApp Keyable { on-key {|event| # This only fires when no widget consumed the key. # So typing 's' is safe even if a text input is focused — # the input would have consumed it first. [match $event~type { :text => { [match $event~text { "s" => [do-stage] "c" => [do-commit] _ => nil }] } :key-down => { if ($event~ctrl and ($event~key == :s)) { [do-save] } } _ => nil }] } }
Chord *strings* (`"ctrl-s"`, `"page_up"`) for keymap lookups are
built with `[keymap/chord $event]` — it accepts either event form
and normalizes to the canonical chord.
## Event Map Shape
Events arrive as maps. The canonical shapes (shared by panels and
SDL surfaces — see the `Surface` protocol):
%{
:type :key-down # named key pressed
:key :page_up # keyword from the Key vocabulary
:ctrl false # mods are booleans: :ctrl :shift :alt
:shift true
:alt false
}
%{
:type :text # printable character(s) typed
:text "s"
}
%{
:type :mouse-down # or :mouse-up
:x 42 # panel-local CELLS
:y 10 # (pixels on SDL surfaces)
:ctrl false :shift false :alt false
}
%{
:type :mouse-move # motion; :dragging true while held
:x 42
:y 10
:dragging false
}
%{
:type :wheel # mouse scroll
:dx 0
:dy -3 # notches, negative = up
:x 42 :y 10 # position
}
Surfaces add `:resized` (`:w`/`:h`), `:closed`, `:focus`
(`:gained`) and `:key-up`, and surface key events carry `:repeat`.
Every surface event carries `:surface` (the surface id). Panels also
deliver `:focus`/`:blur` and `:panel-open`/`:panel-close`.
## What's In `$event`?
| Field | When | Value |
|---|---|---|
| `:type` | always | Event type keyword (see above) |
| `:key` | `:key-down`/`:key-up` | **Keyword** from the Key vocabulary: `:escape`, `:enter`, `:tab`, `:backspace`, `:up`…`:down`, `:page_up`, `:home`, `:f1`…, `:space`, letters `:a`…`:z` (letters mainly with ctrl/alt) |
| `:text` | `:text` events | The typed string (printable characters never arrive as `:key`) |
| `:ctrl`, `:shift`, `:alt` | key + mouse events | Booleans |
| `:x`, `:y` | mouse events | Pointer position — **cells** on panels, **pixels** on SDL surfaces (`tui/px->cell` / `tui/cell->px` convert) |
| `:button` | `:mouse-down`/`:mouse-up` (SDL surfaces) | `:left`, `:middle`, `:right`, `:x1`, `:x2` — not present on panel events |
| `:dx`, `:dy` | `:wheel` | Scroll deltas in notches |
| `:dragging` | `:mouse-move` | True while a button is held |
| `:surface` | surface events | The surface id |
## Quick Reference
| Form | Purpose |
|---|---|
| `[tui/set-widget-tree $atom]` | Register tree for framework dispatch |
| `[tui/dispatch-tree-event $tree $event $bounds]` | Legacy manual pre-pass |
| `[tui/focus-widget "id"]` | Set widget focus slot |
| `[tui/focused-widget-id]` | Read focus slot |
| `[tui/cycle-focus :forward $tree]` | Tab cycling |
| `[tui/clear-focus]` | Drop focus |
| `[keymap/chord $event]` | Canonical chord string (`"ctrl-s"`) for keymap lookups |
| `defimpl App Panel { on-event {|event| … } }` | Legacy bubble handler |
| `defimpl App Keyable { on-key {|event| … } }` | Modern typed bubble (key-down + text) |
| `defimpl App Hittable { hit {|event bounds| … } }` | Mouse dispatch |
| `defimpl App Scrollable { on-scroll {|event| … } }` | Wheel dispatch |