Focus management
Focus in a TUI is exactly as tricky as focus in a browser, and for the same
reasons: there is only one “here” and every widget wants it. FrankenTUI
solves this with a directed focus graph, a linear tab order derived
from per-node tab_index, and a trap stack that confines navigation
when a modal opens.
This page walks through the FocusManager API, how navigation resolves, and
how modals integrate cleanly via focus traps.
The model
Focus state lives in a single object on your model (or near it):
use ftui_widgets::focus::manager::FocusManager;
pub struct Model {
focus: FocusManager,
// … your widget state
}FocusManager owns four things:
| Field | Purpose |
|---|---|
graph: FocusGraph | All focusable nodes plus their directional edges |
current: Option<FocusId> | The node that currently has focus |
trap_stack: Vec<FocusTrap> | Stack of active trap groups (one per modal) |
history: Vec<FocusId> | Undo-able focus history |
Source: focus/manager.rs:74.
Nodes and the graph
A FocusNode carries an ID, a tab_index, an optional group_id, and a
bounding_rect. Nodes are inserted into a FocusGraph:
Source: focus/graph.rs:112.
use ftui_widgets::focus::graph::{FocusGraph, FocusNode, NavDirection};
let mut g = FocusGraph::new();
let a = g.insert(FocusNode::new(focus_id!(a)).with_tab_index(0));
let b = g.insert(FocusNode::new(focus_id!(b)).with_tab_index(1));
let c = g.insert(FocusNode::new(focus_id!(c)).with_tab_index(2));
// Explicit spatial edges: navigating Right from `a` reaches `b`.
g.connect(a, NavDirection::Right, b);
g.connect(b, NavDirection::Right, c);connect(from, dir, to) installs the edge. There is no set_next; if you
want Tab behaviour, you set tab_index on the nodes and
FocusManager::focus_next() computes the cycle.
Most of the time you do not build the graph by hand. Widgets that take focus (buttons, inputs, tabs) register themselves during render, and the focus manager figures out the graph from widget bounding rects.
Tab order
The graph has two notions of “ordered”:
- Per-group tab order (
tab_order(),group_tab_order(group)): focusable nodes sorted ascending bytab_index, ties broken byid. Nodes withtab_index < 0oris_focusable == falseare skipped. - Spatial direction (
navigate(from, dir)): an explicit outgoing edge.
FocusManager::focus_next() uses tab order inside the current group.
focus_prev() is the reverse. Both wrap around.
Core operations
| Method | Purpose |
|---|---|
focus(id) | Explicitly set focus, respecting active traps; returns the previous focus |
blur() | Clear focus |
focus_next() | Tab — advance within the current group |
focus_prev() | Shift+Tab — go back within the current group |
focus_first() / focus_last() | Jump to the first / last focusable in group |
focus_back() | Pop a frame from the history stack |
apply_host_focus(bool) | Window gained or lost focus; restore / save prior logical focus |
push_trap(group_id) | A modal opened; navigation is now confined to group_id |
pop_trap() | The top modal closed; restore the prior trap + focus |
Method signatures are at
focus/manager.rs:149 through :337.
Trap stack for modals
When a modal opens, you do not want Tab to escape into the background. The trap stack solves this:
- The modal pushes a trap with its own
group_id. - Until
pop_trap()is called,focus_next/focus_prevonly cycle through nodes whosegroup_idmatches the trap. - On pop, focus returns to wherever it was before
push_trap.
The ModalStack widget wraps this for you via
push_with_focus — you usually do not call push_trap manually.
Spatial navigation
Arrow-key navigation uses FocusGraph::navigate(from, dir):
use ftui_widgets::focus::graph::NavDirection;
if let Some(target) = focus.graph().navigate(focus.current().unwrap(), NavDirection::Right) {
focus.focus(target);
}Edges may be explicit (set by connect) or computed from bounding rects
by spatial::find_nearest.
The spatial search picks the focusable node nearest in the chosen direction
that does not cross an occluder.
Window focus: apply_host_focus
When the terminal window itself loses focus (another app takes over), you usually want to blur — but you also want to restore the exact node that was focused when the window regains focus.
apply_host_focus(focused: bool) handles this:
apply_host_focus(false)— stashcurrentinto history, clearcurrent.apply_host_focus(true)— restore the stashed focus if valid; otherwise focus the first focusable node in the active trap / group.
The test cases at
focus/manager.rs:953 onward
cover the edge cases (trap active, stashed focus no longer exists, etc.).
Worked example: tabs + form
A screen with a tab strip at top and a form below. Tab should cycle through form fields but not jump into the tab strip. The tab strip is navigated by Left / Right.
use ftui_widgets::focus::graph::{FocusNode, NavDirection};
// Two groups: 0 = tabs, 1 = form fields.
let tab1 = graph.insert(FocusNode::new(focus_id!(tab1))
.with_group(0).with_tab_index(0));
let tab2 = graph.insert(FocusNode::new(focus_id!(tab2))
.with_group(0).with_tab_index(1));
let name = graph.insert(FocusNode::new(focus_id!(name))
.with_group(1).with_tab_index(0));
let email = graph.insert(FocusNode::new(focus_id!(email))
.with_group(1).with_tab_index(1));
let submit = graph.insert(FocusNode::new(focus_id!(submit))
.with_group(1).with_tab_index(2));
// Spatial edges between tabs.
graph.connect(tab1, NavDirection::Right, tab2);
graph.connect(tab2, NavDirection::Left, tab1);In update():
match event {
Event::Key(k) if k.code == KeyCode::Tab => { focus.focus_next(); }
Event::Key(k) if k.code == KeyCode::BackTab => { focus.focus_prev(); }
Event::Key(k) if k.code == KeyCode::Right => {
if let Some(t) = focus.graph().navigate(focus.current().unwrap(), NavDirection::Right) {
focus.focus(t);
}
}
// …
}Tab stays within the current group. Arrow keys follow explicit spatial
edges. When a modal opens, push_trap(2) confines Tab to the modal’s own
group.
Focus indicator
FocusManager carries a FocusIndicator that controls how the focused
widget is visually flagged (outline, underline, highlight). See
focus/indicator.rs.
Widgets check frame.focus_hint(id) during render and adjust their style.
Pitfalls
- Forgetting to call
apply_host_focus. Without it, logical focus persists through window deactivation and arrow keys can surprise the user when they come back. - Mixing up groups and traps.
group_idis a property of the node;push_trap(group)is the mechanism that restricts navigation. You can have multiple groups with no traps active, and the user will Tab between them fine. A trap just filters tab candidates to one group. - Not clearing
currentwhen the focused node is destroyed. If you remove a node from the graph, callgraph.remove(id)— it also drops edges. But you still have to updateFocusManager::currentif it pointed at the removed node.
Where next
How ModalStack::push_with_focus integrates with the trap stack.
How the focus graph drives the accessibility tree.
Focus graph (a11y)Threading focus through a sidebar + main layout.
CompositionText widgets that rely on focus to claim the cursor.
Input + TextareaHow this piece fits in widgets.
Widgets overview