Skip to content

Custom Components

Ratcn's built-ins are not special. RenderCtx::render_component accepts any implementation of the Component trait, and the runtime gives a custom component the same identity, focus, hover, hit-testing, and event routing it gives Button. The kanban demo's KanbanCard — a draggable card with board-aware drop handling — is a complete custom component in about a hundred lines.

Write a component when the demoed behavior needs real event handling or its own interaction identity. Purely decorative content should stay direct paint: build a Ratatui widget and render it with ctx.render_widget. Paint-only content declared as a component costs identity, traversal, and hit-testing for nothing.

The Trait

rust
impl Component<AppState, Msg> for MyComponent {
    fn render(&mut self, ctx: &mut RenderCtx<'_, '_, AppState, Msg>) { ... }

    fn handle_event(
        &mut self,
        event: &Event,
        state: &AppState,
        ctx: &mut EventCtx<'_>,
    ) -> EventResult<Msg> { ... }

    fn is_focusable(&self, state: &AppState) -> bool { ... }

    fn interaction_area(&self, area: Rect) -> Rect { ... }
}

Every method except render has a default. is_focusable defaults to false; override it for anything that should take part in Tab traversal. interaction_area defaults to returning the supplied paint area unchanged. Override it when interactive pixels occupy only part of the allocation. The runtime still paints with the supplied area, but retains the returned area for focus, hit-testing, pointer capture, and event routing. A non-empty result must be fully contained in the supplied paint area; otherwise rendering panics and the previous interaction surface remains active. Returning an area with zero width or height keeps the component's identity and paint but excludes it and its descendants from interaction for that surface. scope_options and resolve matter only for composites (below). MeasuredComponent adds a measure method so containers such as the Dialog action row can size a component before rendering it.

A reusable component is worth splitting into the library's two halves: a stateless paint widget that only draws, and the Component that owns behavior and paints by constructing the widget. Keep shared vocabulary — dimensions, variants, width() — on the paint widget so layout constraints and actual paint cannot disagree. A one-off app component can skip the split and paint directly.

Four Kinds of Data, Two Read Moments

This is the part that must be right. A component instance is created during one render pass and then retained, inert, as the event-routing surface until the next successful pass replaces it. Nothing re-renders when state changes; the instance can be arbitrarily stale relative to app state by the time an event reaches it. Everything a component carries falls into one of four kinds, and each kind has one correct read moment.

Declaration props — label, disabledness, variant, colors. Plain values, resolved from state while declaring (.disabled(state.saving)), frame-old on purpose. The retained value describes what the user saw, and what the user saw is authoritative for what their event means: a button that was enabled on screen when clicked should press, even if state disabled it a moment ago. update validates non-repeatable intent.

Controlled bindings — the value being edited, the focused row, the scroll offset, the selection. Stored as reader closures (Fn(&S) -> ...) and invoked inside handle_event with the state passed to it, which is current at the moment of the event. These must never be frozen into the declaration, because consecutive events compose. Two keystrokes can arrive between frames — under key repeat, paste, an event-draining loop, or any browser backend — and each edit must start from the state the previous edit produced, not from the last render:

text
render N     state.name = ""      component retained
key 'a'      reads "" from current state → emits "a";  update persists
key 'b'      reads "a" from current state → emits "ab"   ← no render between
render N+1   paints "ab"

Held as a snapshot instead, key b would also start from "" and the first keystroke would be lost. The rule: an edit acts on the state as of the previous edit, not as of the previous render.

Render-derived caches — whatever beyond the area itself is needed to interpret later pointer events, such as a scroll offset used for hit-testing. Set them in render, read them in handle_event. They stay valid because they live inside the same retained instance that events route through. They must never become a second copy of semantic state. (The declared area needs no cache: EventCtx::area hands handle_event the same rect the event was hit-tested against.)

Transient interaction state — mechanics that must survive the instance being replaced mid-gesture, such as a drag anchor. A fresh instance is built every frame, so a field would reset; ctx.transient::<T>() stores one typed value per identity path that persists across successful rebuilds while the path stays declared. See Dragging for the standard use, and EventCtx::capture_pointer for owning a gesture across movement.

Handling Events

Return EventResult::Ignored when routing should continue to the parent, Consumed when the event is handled with no state change, and Emit(msg) to send exactly one message to the app's update. Only Ignored bubbles. Components never mutate app state; the message is the only output.

For a primary-button Down, Ignored also permits the runtime's focus fallback after bubbling. Pointer capture is independent: a component can call ctx.capture_pointer(MouseButton::Left) and still return Ignored to capture the gesture and receive the normal focus change. Consumed vetoes fallback; Emit(msg) takes precedence and returns the component message.

Match the built-ins' conventions: name event-wiring builders on_<event>, keep a continuously tracked value (<thing> / on_<thing>_change) distinct from a committed choice (selected / on_select), and ignore events while disabled rather than becoming unfocusable-but-reactive.

Composites

A component may declare descendants from its own render through the same RenderCtx methods the root uses. Children nest under the component's identity. Two contracts apply, both validated by the runtime before the surface is committed:

  • scope_options must return ScopeOptions::default().composite() so the runtime knows a descendant scope opens before render begins.
  • Add .focusable_descendants() exactly when this frame's declarations will include at least one structurally focusable descendant. When that varies with state, Component::resolve(&mut self, state) runs before scope_options is read — compute the claim there. Dialog uses this hook to fold its action row's focusability into the claim.

Paint container pixels before declaring descendants: retained hit order follows declaration order and cannot see direct frame paint performed afterward.

The bookkeeping these contracts force on a composite is packaged in ratcn::runtime: BodySlot holds a user-supplied FnOnce body through its configured/painted lifecycle, and PreparedChildren holds measured standard children across the gap between early resolution (so the focus claim can be answered) and rendering. Dialog is built on both; a custom composite can be too.

Checklist

  • Semantic state lives in the app; the component reads it and emits messages.
  • Props that describe the declaration: plain values, set while declaring.
  • State that events compose against: reader closures, read in handle_event.
  • Geometry needed to interpret events: the declared area comes from EventCtx::area; cache anything beyond it in render.
  • Interactive geometry within the paint area: express it with interaction_area.
  • Gesture mechanics that outlive the instance: ctx.transient.
  • is_focusable reflects the same condition that makes events ignored.
  • One Emit per event; Ignored only when a parent should get a chance.