Skip to main content

ui/
focus.rs

1//! Keyboard focus traversal — `tab` and `shift-tab` between controls.
2//!
3//! gpui has all the machinery and none of it is on by default: a focus handle
4//! carries a `tab_index` and a `tab_stop` flag, `.track_focus` registers the
5//! handle for the frame, and [`Window::focus_next`] walks the order — but
6//! `tab_stop` starts `false` and gpui binds no keys. This module turns it on.
7//!
8//! # Order is paint order
9//!
10//! gpui sorts tab stops by their `tab_index` path and then by insertion, so
11//! leaving every index at 0 yields the order the controls are painted in.
12//! Nothing has to be numbered by hand, and inserting a control in the middle of
13//! a form does not renumber the rest — which is the failure mode that makes
14//! HTML `tabindex` a liability.
15//!
16//! # Where the handle lives
17//!
18//! Most of this crate is `fn(&Theme, ..) -> Div`: stateless, with the app
19//! owning whether a checkbox is checked. Focus is more of that state, so the
20//! app owns the handle too and [`focusable`] wires it up. Giving every widget a
21//! handle of its own would mean giving every widget an identity and a lifetime,
22//! which is the entity machinery [`crate::input::TextField`] needs and a
23//! checkbox does not.
24//!
25//! ```ignore
26//! ui::focus::init(cx);                    // once, at startup
27//!
28//! // ..and on the root view, so `tab` works wherever focus currently is:
29//! focus::traversal(div().track_focus(&self.focus_handle))
30//!     .child(focus::focusable(&theme, &self.ok_focus, popover::button(&theme, "OK", "ok")))
31//! ```
32
33use gpui::{App, Div, FocusHandle, KeyBinding, Window, actions, prelude::*};
34
35use theme::Theme;
36
37actions!(
38    bezel_focus,
39    [FocusNext, FocusPrev, Activate, Decrement, Increment]
40);
41
42/// Claimed by every [`focusable`] control, so `enter` and `space` mean "press
43/// this" only where something is actually focused.
44///
45/// Scoping matters here: [`crate::palette`] and [`crate::combobox`] both bind
46/// `enter` for their own lists, and a multi-line field binds it to insert a
47/// newline. A focused control sits deeper in the focus path than any of them,
48/// so it wins `enter` while focused and gives it straight back afterwards.
49pub const CONTROL_KEY_CONTEXT: &str = "Control";
50
51/// Added to a surface's key context when `tab` is its own key — a document
52/// nests a list with it. [`traversal`] stands down while that surface holds
53/// focus.
54///
55/// A mark, not a predicate on the binding: any predicate fails against an empty
56/// context stack, which is what an element with no key context dispatches
57/// against.
58///
59/// ```ignore
60/// let mut context = KeyContext::default();
61/// context.add(MY_CONTEXT);
62/// context.add(focus::CLAIMS_TAB);
63/// div().key_context(context).track_focus(&self.focus_handle)
64/// ```
65pub const CLAIMS_TAB: &str = "ClaimsTab";
66
67/// Install the bindings — [`bindings`], bound. Call once at startup.
68pub fn init(cx: &mut App) {
69    cx.bind_keys(bindings());
70}
71
72/// Traversal's keymap, as data, so an app can have it without having to
73/// take it — see [`crate::keys`] for layering over it or taking a chord
74/// away.
75///
76/// `tab` and `shift-tab`, global rather than scoped to a context: traversal is a property of the window, not
77/// of whatever happens to be focused.
78///
79/// Nothing in this crate claims `tab` for itself, deliberately. A multi-line
80/// field could reasonably insert one, but trapping `tab` inside a text box is
81/// the classic way to make a form impossible to leave by keyboard. A surface
82/// where the key is structural says so with [`CLAIMS_TAB`] instead.
83///
84/// [`Decrement`]/[`Increment`] on `left`/`right` are for a control that holds a
85/// *value* rather than a press — [`slider`](crate::widgets::Controls::slider)
86/// is the one. They
87/// carry no step: only the caller knows the range, and a library that picked
88/// one would be picking it for a percentage and a font size alike.
89pub fn bindings() -> Vec<KeyBinding> {
90    let mut bindings = Vec::new();
91    bindings.extend([
92        KeyBinding::new("tab", FocusNext, None),
93        KeyBinding::new("shift-tab", FocusPrev, None),
94        // Both, because both are standard and they disagree by platform: the
95        // web and Windows press a focused button with `space`, macOS with
96        // `enter`. Scoped to a focused control, neither is ambiguous.
97        KeyBinding::new("enter", Activate, Some(CONTROL_KEY_CONTEXT)),
98        KeyBinding::new("space", Activate, Some(CONTROL_KEY_CONTEXT)),
99        KeyBinding::new("left", Decrement, Some(CONTROL_KEY_CONTEXT)),
100        KeyBinding::new("right", Increment, Some(CONTROL_KEY_CONTEXT)),
101    ]);
102
103    bindings
104}
105
106/// Attach the traversal handlers, normally to the app's root element.
107///
108/// It has to live on an element rather than on the app because moving focus
109/// needs a [`Window`], and an app-level action handler only gets an [`App`].
110///
111/// Where the focused surface [claims the key](CLAIMS_TAB), both handlers
112/// propagate instead: an action handler stops propagation by default, and
113/// continuing it is what sends gpui on to the next binding the chord matched.
114pub fn traversal(el: Div) -> Div {
115    el.on_action(|_: &FocusNext, window: &mut Window, cx: &mut App| {
116        if claims_tab(window) {
117            return cx.propagate();
118        }
119        window.focus_next(cx);
120    })
121    .on_action(|_: &FocusPrev, window: &mut Window, cx: &mut App| {
122        if claims_tab(window) {
123            return cx.propagate();
124        }
125        window.focus_prev(cx);
126    })
127}
128
129/// The innermost context alone, not any in the path: a field *inside* a
130/// claiming surface still means "next control" by `tab`.
131fn claims_tab(window: &Window) -> bool {
132    window
133        .context_stack()
134        .last()
135        .is_some_and(|context| context.contains(CLAIMS_TAB))
136}
137
138/// Put a stateless control into the tab order, show when it holds focus, and
139/// let `enter`/`space` press it.
140///
141/// The ring is the same one [`crate::input::TextField`] paints — the border in
142/// [`Theme::ring`] — so a focused button and a focused field read alike.
143///
144/// Keyboard focus only, like CSS `:focus-visible`. A control also takes focus
145/// when clicked, and a ring that landed on it there would outline a slider for
146/// the whole drag — the pointer already says which control is being used.
147///
148/// It lands on the control's *own* border, which is why every control in
149/// [`crate::widgets`] carries one even where it paints nothing: gpui sizes
150/// border-box, so a border that only appeared on focus would move the content
151/// under it by a pixel. A ring wrapped *around* the control instead would cost
152/// every one of them a radius parameter, and would prise a focused tab off the
153/// hairline its underline has to overlap.
154///
155/// Pressing dispatches [`Activate`]. Use [`pressable`] to route it and a click
156/// through one callback, or handle it separately for custom interaction.
157pub fn focusable(theme: &Theme, handle: &FocusHandle, el: Div) -> Div {
158    // `tab_stop` writes through to the shared focus entry, so re-asserting it
159    // every render is free and keeps the flag next to the element that wants
160    // it, rather than at whatever distant place the handle was constructed.
161    let handle = handle.clone().tab_stop(true);
162    el.key_context(CONTROL_KEY_CONTEXT)
163        .track_focus(&handle)
164        .focus_visible(|style| style.border_color(theme.ring))
165}
166
167/// One activation path for pointer and keyboard, with a shared enabled gate.
168/// The caller still owns the value changed by the callback.
169pub fn pressable(
170    theme: &Theme,
171    handle: &FocusHandle,
172    el: gpui::Stateful<Div>,
173    enabled: bool,
174    activate: impl Fn(&(), &mut Window, &mut App) + 'static,
175) -> gpui::Stateful<Div> {
176    let activate = std::rc::Rc::new(activate);
177    let click = activate.clone();
178    el.key_context(CONTROL_KEY_CONTEXT)
179        .track_focus(&handle.clone().tab_stop(enabled))
180        .focus_visible(|style| style.border_color(theme.ring))
181        .on_click(move |_, window, cx| {
182            if enabled {
183                click(&(), window, cx);
184            }
185        })
186        .on_action(move |_: &Activate, window, cx| {
187            if enabled {
188                activate(&(), window, cx);
189            }
190        })
191}