Skip to content

Focus, Hover, and Identity

Every declared component or scope has an ID, and its full identity is the path of IDs from the root down to it. IDs must be unique among siblings; the same ID may appear under different parents. Fixed children pass a plain &'static str; data-driven children build a ChildId::Dynamic once from runtime data (say, number.to_string().into()) and store it, so the item keeps its identity when it moves or the list reorders. The Kanban demo uses this so each card keeps its focus and drag state when dragged between columns.

Scopes create the nesting. A scope is a named grouping with its own path segment and focus boundary — no component needed:

rust
ctx.scope(
    "editor",
    pane_area,
    ScopeOptions::default()
        .tab_wrap(TabWrap::Wrap)
        .focusable_descendants(),
    |ctx| {
        ctx.render_component(
            "title",
            Input::new()
                .value(|state: &AppState| &state.title)
                .on_change(Msg::TitleChanged),
            title_area,
        );
    },
);

The input's path is editor/title. Another scope may contain its own title, but a second title directly under editor is a declaration error.

Focus

Focus is a path stored in your app state — a FocusState bound with Ratcn::focus(read, on_change). Focus changes come back as messages for your update to store, like every other state change.

You never have to compute a starting focus: an empty path means "default startup focus", and the runtime resolves it to the first focusable component it finds. The first time the user moves focus, your app receives a concrete path to store.

Tab order follows declaration order. TabWrap::Wrap cycles within a scope; TabWrap::Escape lets Tab leave it and continue in the parent. Shift+Tab walks backwards.

Focus keys jump between panes: a focus_key binding on the root or a scope maps a key chord to a path, and focus lands on that target's first focusable leaf. Character chords ignore Shift and letter case, while Ctrl and Alt must match exactly; the same matching is available to your own hotkey checks as KeyChord::matches. There is no per-pane focus memory — jumping back into a pane starts at its first focusable leaf again.

Parked focus. If the focused component disappears, is disabled, or collapses to zero size, Ratcn keeps the stored path as-is rather than guessing a replacement — focus is parked. A parked target can still render as focused when it comes back, disabled controls ignore input meanwhile, and Tab simply moves on to an eligible target. The one exception is an open modal: focus outside it is pulled to the modal, because the modal owns input until it closes. Why the library never silently retargets focus is covered in Design Decisions.

Programmatic focus. FocusState::intent(path) names a path without validating it — use it when app policy points focus somewhere that may not exist yet, such as into a modal that opens this frame. Ratcn::focus_path(path) instead validates against the last rendered frame and returns None for missing, disabled, or covered targets; if the path ends at a scope, it descends to the scope's first focusable leaf.

Telling Ratcn What Can Be Focused

A scope declares its focus role up front, in its ScopeOptions:

  • focusable_descendants() promises that the scope will declare at least one focusable child this frame. The runtime verifies the promise after the declarations run, and an incorrect promise fails the render — loudly, with the scope's ID, rather than silently misrouting focus.
  • focusable() makes the scope itself the Tab stop — for a pane with nothing focusable inside, such as a read-only chart.

When focusability varies with state — say all of a pane's controls disable together — derive the promise from the same state the children read, so the two cannot drift apart. The promise exists because rendering is single-pass: a parent paints its focus highlight before its children are declared, so it must be told, not discover, whether focus can descend into it. The full reasoning lives in Design Decisions.

Hover

Hover is a second app-owned path, bound with Ratcn::hover, tracking what the pointer is over. It is deliberately independent of focus: typing keeps going to the focused field while the mouse drifts across other controls. A component can still highlight under the pointer through RenderCtx::hovered.

If you want focus to follow the mouse, opt in with Ratcn::hover_focus() at the root or ScopeOptions::hover_focus() on a scope. Everywhere else, hover and focus stay independent.

A stored hover path can go stale — its target removed, moved, or covered. Ratcn resolves the path against the latest frame and simply renders nothing hovered until the next pointer motion catches app state up, so don't treat a hover path as proof a component exists.

Gesture State

Some interaction state is too short-lived for your app state but must survive the frame-by-frame rebuild of component instances — a drag anchor, for example. EventCtx::transient stores such values by identity path: they persist while the path stays declared and are cleaned up when it disappears. Durable values still belong in app state. See Dragging for the standard use.