Skip to Content
ftui-widgetsFocus management

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:

FieldPurpose
graph: FocusGraphAll 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 by tab_index, ties broken by id. Nodes with tab_index < 0 or is_focusable == false are 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

MethodPurpose
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:

  1. The modal pushes a trap with its own group_id.
  2. Until pop_trap() is called, focus_next / focus_prev only cycle through nodes whose group_id matches the trap.
  3. 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) — stash current into history, clear current.
  • 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_id is 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 current when the focused node is destroyed. If you remove a node from the graph, call graph.remove(id) — it also drops edges. But you still have to update FocusManager::current if it pointed at the removed node.

Where next