Skip to Content

Focus graph

The focus graph is the runtime’s view of which widget currently has the keyboard, where Tab goes next, and what is reachable at all. It sits between the event source and the widget tree: key events arrive, the focus manager decides who receives them, and the accessibility tree publishes the result so screen readers can follow along.

The focus manager lives in ftui-widgets/src/focus/graph.rs for the reachability model, manager.rs for the lifecycle, indicator.rs for the visible focus ring, and spatial.rs for directional navigation. Widgets surface themselves as focusable by implementing Accessible with an interactive role; the graph picks them up automatically.

The two questions focus answers

Every frame, the focus manager answers two questions for each widget in the tree:

  1. Can this widget receive focus at all? Decided by role (A11yRole::is_interactive()), by the disabled state flag, and by whether a modal is currently trapping focus elsewhere.
  2. What is the next focusable widget after this one, in each direction? Tab / Shift-Tab follow a linearized document order; arrow keys use the spatial graph.

Both questions are answered from the same data the a11y tree is built from. There is no second source of truth.

Tab order

Tab order is derived from document order of the widget tree — the order widgets were laid out, flattened depth-first. That is the same order the a11y tree’s children lists encode, so screen-reader reading order and Tab order are guaranteed to agree.

Three controls tune the default:

  • A11yRole::is_interactive() — only interactive roles are reached by Tab. Button, TextInput, Checkbox, RadioButton, Slider, Tab, and MenuItem return true; everything else is skipped.
  • A11yState { disabled: true, .. } — disabled widgets are skipped.
  • The container’s A11yRoleGroup, Toolbar, List, and similar containers pass focus through to their children instead of catching it themselves.

Shift-Tab is the reverse traversal. Wrap-around is on by default at the window boundary and configurable per Program.

Arrow keys — the spatial graph

spatial.rs builds a directional adjacency from bounding rectangles: for each focusable node, find the nearest focusable node to the left / right / up / down. Distance is measured as an axis-aligned “how far do I have to travel in this direction before I land on another rectangle” metric. Results get cached per frame, because bounds rarely change within a frame.

Arrow navigation is opt-in per container widget. Lists, menus, and tables enable it by default; generic layout containers do not, because “arrow left from this button” often has no obvious target in free-form UIs.

The focus indicator

indicator.rs owns the visible focus ring — the highlight around the active widget. It subscribes to focus transitions, looks up the target’s bounds from the a11y tree, and paints a themed outline during the next frame. Because the indicator reads from the same tree screen readers consume, the visual focus and the announced focus never drift.

The focus manager

┌────────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ Event source │ ───> │ FocusManager │ ───> │ Widget │ │ (key / mouse / …) │ │ · routing │ │ (the one that │ └────────────────────┘ │ · trap stack │ │ got the key) │ │ · tab computed │ └────────────────┘ └────────┬─────────┘ focus_id + a11y state ┌────────▼─────────┐ │ A11yTree │ │ (focused node) │ └──────────────────┘

Responsibilities:

  • Routing. Every key event is first offered to the focused widget. Unhandled events bubble up toward the root, then to global shortcuts.
  • Programmatic focus. Cmd::SetFocus(id) lets a widget hand focus to a sibling (autocomplete → accepted, dialog → default button).
  • Trap stack. Modals push a trap scope; focus cannot escape that scope until the modal closes. More below.
  • Tab cache. The linearized Tab order is computed once per frame from the widget layout and reused for every Tab press that frame.

The modal trap stack

A modal dialog must not let Tab wander out into the background UI. The focus manager uses a stack of trap scopes, not a single “modal open” flag, because modals can legitimately nest: a confirmation dialog inside a multi-step wizard inside an app.

The modal widget pushes a trap scope with its root node’s ID:

focus.push_trap(modal_root_id); focus.set_focus(modal_initial_focus_id);

Tab is constrained

While the trap is on top of the stack, Tab cycling is bounded to descendants of modal_root_id. Shift-Tab from the first descendant wraps to the last; Tab from the last wraps to the first. Reaching out is impossible.

focus.pop_trap(modal_root_id); // must match the id on top

Focus is restored to whatever widget had focus before the modal opened. This is the detail that makes modal dismissal feel correct: the user does not have to re-find their place.

Nested traps: a second modal pushes a second scope. Pop is LIFO. Trying to pop a scope that is not on top is a programming error and panics in debug, warns and ignores in release.

Integration with widgets

Every focusable widget in ftui-widgets follows the same contract:

  1. Implement Accessible with an interactive role and whatever A11yState flags apply.
  2. In the update path, accept or ignore events based on self.focused (which the manager sets before dispatch).
  3. Emit Cmd::SetFocus(next_id) when the widget’s own logic demands a focus change (for example, when a multi-step form advances to the next step).

The FocusManager API that widgets call is small on purpose:

CallEffect
focus(id)Set focus to id within the current trap scope.
focused()Current Option<u64>.
push_trap(root_id)Constrain Tab / arrows to the subtree of root_id.
pop_trap(root_id)Restore the previous trap scope and prior focus.
tab_next() / tab_prev()Move focus in document order within the active scope.
spatial_next(direction)Move focus using the directional graph.

Everything else — dispatch, bubbling, indicator paint — is internal.

The relationship to the a11y tree

  • The A11yTree’s focused_id is always the focus manager’s current target. The tree builder asks the manager, not the other way around.
  • A11yTreeDiff::focus_changed is populated whenever the manager transitions. Platform bridges (future Phase 3) forward that transition as an accessibilityFocusChanged notification.
  • The trap stack does not appear in the a11y tree directly. Instead, widgets behind an active modal simply do not emit Accessible output while the modal is open — they are not invisible, but they are not interactive, and the tree mirrors that.

Pitfalls

  • Do not skip Accessible for interactive widgets. A widget that never announces itself is also a widget Tab will not reach.
  • Do not treat push_trap as optional for modals. Without it, Tab leaks into the background UI and focus lands in places the user cannot see.
  • Do not set focus during layout. Cmd::SetFocus lands in the update phase. Mutating focus mid-layout races against indicator painting and causes one-frame flashes.
  • Do not rebuild the Tab cache manually. The manager does it once per frame. Repeated requests from widgets fight over the cache and cost real CPU.

See also