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:
- Can this widget receive focus at all? Decided by role
(
A11yRole::is_interactive()), by thedisabledstate flag, and by whether a modal is currently trapping focus elsewhere. - 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, andMenuItemreturn true; everything else is skipped.A11yState { disabled: true, .. }— disabled widgets are skipped.- The container’s
A11yRole—Group,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.
Modal mounts
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.
Modal unmounts
focus.pop_trap(modal_root_id); // must match the id on topFocus 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:
- Implement
Accessiblewith an interactive role and whateverA11yStateflags apply. - In the update path, accept or ignore events based on
self.focused(which the manager sets before dispatch). - 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:
| Call | Effect |
|---|---|
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’sfocused_idis always the focus manager’s current target. The tree builder asks the manager, not the other way around. A11yTreeDiff::focus_changedis populated whenever the manager transitions. Platform bridges (future Phase 3) forward that transition as anaccessibilityFocusChangednotification.- The trap stack does not appear in the a11y tree directly. Instead,
widgets behind an active modal simply do not emit
Accessibleoutput while the modal is open — they are not invisible, but they are not interactive, and the tree mirrors that.
Pitfalls
- Do not skip
Accessiblefor interactive widgets. A widget that never announces itself is also a widget Tab will not reach. - Do not treat
push_trapas 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::SetFocuslands 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
- Accessibility tree — what focus transitions publish
- Contrast checking — the other accessibility primitive
- Bidi / RTL support — RTL affects spatial navigation direction mapping
- Widgets — focus · Widgets — modal stack · Demo showcase overview