Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Focus

Focus belongs to your app, not to the widgets. A widget is told whether it is focused through .focused(bool) and styles itself accordingly. It never decides on its own, because only the app knows what else is on screen and what the tab order should be.

Focus<T> is the small helper that keeps that decision in one place.

extern crate tuile;
use tuile::prelude::*;
fn main() {
#[derive(Clone, Copy, PartialEq, Debug)]
enum Id { Name, Email, Save }

let mut focus = Focus::new([Id::Name, Id::Email, Id::Save]);
assert!(focus.is(Id::Name));
focus.next();
assert!(focus.is(Id::Email));
focus.prev();
focus.prev();
assert!(focus.is(Id::Save));     // wrapped around
}

T is any Copy + PartialEq. An enum is the usual choice: adding a field to the enum and forgetting to render it is then a compile error in the match, rather than a widget you cannot reach with Tab.

Wiring it up

Two calls. One in event to move the ring, one per widget in draw to report the answer:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Save }
struct Page { name: InputState, save: ButtonState, focus: Focus<Id> }
impl Page {
fn event(&mut self, ev: &Event) -> Outcome {
    if let Event::Key(k) = ev
        && self.focus.handle_key(*k).is_consumed()
    {
        return Outcome::Consumed;
    }
    match self.focus.current() {
        Some(Id::Name) => self.name.handle(ev),
        Some(Id::Save) => self.save.handle(ev),
        None => Outcome::Ignored,
    }
}

fn draw(&mut self, area: Rect, buf: &mut Buffer) {
    let [a, b] = Layout::vertical([Constraint::Length(3); 2]).areas(area);
    Input::new().focused(self.focus.is(Id::Name)).render(a, buf, &mut self.name);
    Button::new("Save").focused(self.focus.is(Id::Save)).render(b, buf, &mut self.save);
}
}
fn main() {}

Focus::handle_key consumes Tab and Shift-Tab (crossterm reports the second as BackTab) and ignores everything else. Call it before dispatching to the focused widget, or a text field will swallow the Tab.

The API

MethodEffect
new(order)Build from anything that converts into a Vec<T>; starts at index 0
current()Option<T>, None only when the order is empty
is(id)The focus test to pass to .focused(..)
set(id)Jump to an id; returns false if it is not in the order
next() / prev()Step, honouring wrap
index() / set_index(i)Positional access; set_index clamps
order()The current order as a slice
set_order(order)Replace the order, keeping the current id if it survives
wrapPublic field, default true. Set false to stop at the ends

Dynamic tab orders

A collapsed section or a hidden field should not be a stop on the ring. set_order rebuilds it and keeps the user where they are when possible:

extern crate tuile;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Advanced, Timeout, Save }
fn demo(focus: &mut Focus<Id>, advanced_open: bool) {
let order: Vec<Id> = if advanced_open {
    vec![Id::Name, Id::Advanced, Id::Timeout, Id::Save]
} else {
    vec![Id::Name, Id::Advanced, Id::Save]
};
focus.set_order(order);
}
fn main() {}

If the focused id is still in the new order, focus follows it. If it disappeared (the user collapsed the section that held it), focus falls back to index 0. Rebuilding the order every frame is fine: it is a Vec of Copy values and a linear search.

Clicking should move focus

Terminal users expect a click to focus what they clicked. Widgets do not do this for you, because the widget cannot know its own id. Do it where you dispatch the mouse event:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
#[derive(Clone, Copy, PartialEq)] enum Id { Name, Save }
struct Page { name: InputState, save: ButtonState, focus: Focus<Id> }
impl Page {
fn on_mouse(&mut self, ev: &Event) -> Outcome {
    let n = self.name.handle(ev);
    if n.is_consumed() {
        self.focus.set(Id::Name);
    }
    let s = self.save.handle(ev);
    if s.is_changed() {
        self.focus.set(Id::Save);
    }
    n | s
}
}
fn main() {}

Use is_consumed() for widgets where a press should focus (a text field: the click also places the cursor) and is_changed() for widgets where only a completed action should (a button: a press that slides off and releases elsewhere is a cancelled click).

Focus inside a widget

Some widgets have internal cursors: the selected row of a list, the current cell of a table, the open item of a menu. That is not app focus and Focus<T> knows nothing about it. The widget owns it, moves it with the arrow keys, and only sees those keys while your app says it is focused.

Nested focus rings work the same way: a panel with three fields can own a Focus<FieldId> and be a single stop in the parent’s Focus<PanelId>. Forward keys down only while the parent ring points at that panel, and let the child ring stop wrapping (wrap = false) if you want Tab to escape the panel at its ends.

Blurred styling

Widgets render differently when not focused, and the difference is a theme role, not an on-off switch. border_blurred, cursor_blurred_bg and the muted text roles are what a blurred widget uses. A text area keeps showing its cursor when blurred, in a dimmer colour, so a reader can still see where typing would resume. The theme roles table lists the pairs.