Skip to main content

gpui_base/
toolbar.rs

1use gpui::{
2    AnyElement, App, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, IntoElement,
3    KeyDownEvent, ParentElement, RenderOnce, Role, SharedString, Stateful,
4    StatefulInteractiveElement, StyleRefinement, Styled, Window, accesskit, div,
5    prelude::FluentBuilder as _,
6};
7use smallvec::SmallVec;
8
9use crate::StyledExt as _;
10
11/// Upper bound on tab-stop hops when wrapping focus back into the toolbar, so
12/// a toolbar whose items all vanished from the tab order can never hang the
13/// key handler. Mirrors `Root`'s focus-trap loop bound.
14const MAX_FOCUS_ATTEMPTS: usize = 100;
15
16/// An unstyled container that groups a set of controls and owns roving
17/// keyboard focus among them.
18///
19/// This is the behavior primitive behind a styled toolbar. The container
20/// exposes `Toolbar` semantics to assistive technology (`Role::Toolbar` with
21/// an orientation) and moves focus between its focusable descendants with the
22/// arrow keys, so applications do not have to reimplement the roving-focus
23/// contract per toolbar. It works with any focusable children — buttons,
24/// menu triggers, inputs — because traversal walks the rendered tab stops
25/// and constrains them to this subtree, mirroring how `Root` constrains
26/// focus-trap cycling.
27///
28/// Keyboard contract:
29///
30/// - Left and Right move focus to the previous or next focusable descendant,
31///   wrapping around at either end (the same default as Base UI's `loopFocus`).
32/// - When `disabled`, the arrow keys do nothing. Hosted controls must be
33///   disabled by their owner; the flag only suppresses the toolbar's own
34///   navigation.
35///
36/// The container is not itself a tab stop, so ordinary `Tab` traversal enters
37/// and leaves the toolbar through its items, matching the ARIA toolbar
38/// pattern.
39///
40/// An input hosted inside the toolbar keeps its own arrow-key behavior: text
41/// inputs consume the arrow keys for caret movement before the toolbar sees
42/// them. Place inputs at the trailing end of a horizontal toolbar, as the
43/// Base UI Toolbar recommends.
44#[derive(IntoElement)]
45pub struct Toolbar {
46    id: ElementId,
47    base: Stateful<Div>,
48    style: StyleRefinement,
49    disabled: bool,
50    children: SmallVec<[AnyElement; 4]>,
51}
52
53impl Toolbar {
54    pub fn new(id: impl Into<ElementId>) -> Self {
55        let id = id.into();
56        Self {
57            base: div().id(id.clone()),
58            style: StyleRefinement::default(),
59            disabled: false,
60            children: SmallVec::new(),
61            id,
62        }
63    }
64
65    /// Disables the toolbar's own keyboard navigation. Hosted controls are
66    /// not automatically disabled.
67    pub fn disabled(mut self, disabled: bool) -> Self {
68        self.disabled = disabled;
69        self
70    }
71}
72
73fn step_focus(window: &mut Window, cx: &mut App, forward: bool) {
74    if forward {
75        window.focus_next(cx);
76    } else {
77        window.focus_prev(cx);
78    }
79}
80
81/// Move focus to the next (or previous) focusable element inside `container`.
82///
83/// Traversal walks the window's tab stops like `Root`'s focus-trap cycling:
84/// step once, and if the step landed outside the container, keep stepping
85/// until the focus re-enters or comes back to where it started (in which
86/// case the toolbar has no other focusable item and focus stays put).
87fn move_focus(container: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
88    let Some(start) = window.focused(cx) else {
89        return;
90    };
91
92    step_focus(window, cx, forward);
93    if container.contains_focused(window, cx)
94        && window.focused(cx).is_some_and(|focused| focused != start)
95    {
96        return;
97    }
98
99    for _ in 0..MAX_FOCUS_ATTEMPTS {
100        step_focus(window, cx, forward);
101        if container.contains_focused(window, cx)
102            && window.focused(cx).is_some_and(|focused| focused != start)
103        {
104            return;
105        }
106        if window.focused(cx).is_some_and(|focused| focused == start) {
107            break;
108        }
109    }
110
111    window.focus(&start, cx);
112}
113
114fn handle_key_down(
115    disabled: bool,
116    container: &FocusHandle,
117    event: &KeyDownEvent,
118    window: &mut Window,
119    cx: &mut App,
120) {
121    if disabled {
122        return;
123    }
124
125    let forward = match event.keystroke.key.as_str() {
126        "left" => Some(false),
127        "right" => Some(true),
128        _ => None,
129    };
130
131    if let Some(forward) = forward {
132        move_focus(container, forward, window, cx);
133        cx.stop_propagation();
134    }
135}
136
137impl Styled for Toolbar {
138    fn style(&mut self) -> &mut StyleRefinement {
139        &mut self.style
140    }
141}
142
143impl ParentElement for Toolbar {
144    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
145        self.children.extend(elements);
146    }
147}
148
149impl InteractiveElement for Toolbar {
150    fn interactivity(&mut self) -> &mut Interactivity {
151        self.base.interactivity()
152    }
153}
154
155impl StatefulInteractiveElement for Toolbar {}
156
157impl RenderOnce for Toolbar {
158    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
159        let Self {
160            base,
161            style,
162            disabled,
163            children,
164            ..
165        } = self;
166
167        // The handle must survive across frames so containment checks and the
168        // key handler refer to the same dispatch-tree node; button.rs uses
169        // the same keyed-state pattern for its own focus handle.
170        let focus_handle = window
171            .use_keyed_state(self.id.clone(), cx, |_, cx| {
172                cx.focus_handle().tab_stop(false)
173            })
174            .read(cx)
175            .clone();
176        let key_handler = {
177            let focus_handle = focus_handle.clone();
178            move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| {
179                handle_key_down(disabled, &focus_handle, event, window, cx);
180            }
181        };
182
183        base.track_focus(&focus_handle)
184            .role(Role::Toolbar)
185            .aria_orientation(accesskit::Orientation::Horizontal)
186            .on_key_down(key_handler)
187            .children(children)
188            .refine_style(&style)
189    }
190}
191
192/// A semantic subgroup of items within a [`Toolbar`].
193///
194/// The group carries no behavior or styling of its own: the surrounding
195/// toolbar's roving arrow-key focus traverses its items exactly like the
196/// toolbar's direct children, because containment follows the element tree.
197/// Its value is structure — assistive technology announces the group and its
198/// accessible name, so a run of related controls reads as one unit ("Undo",
199/// "Redo" inside a "History" group).
200///
201/// Unlike Base UI's `Toolbar.Group`, the group cannot disable its children.
202/// That API propagates through React context into Base UI's own button
203/// primitives; GPUI composition offers no equivalent for arbitrary children,
204/// and the platform a11y layer exposes no disabled state for a container
205/// node. Disabling the hosted controls is the group owner's job.
206#[derive(IntoElement)]
207pub struct ToolbarGroup {
208    base: Stateful<Div>,
209    style: StyleRefinement,
210    label: Option<SharedString>,
211    children: SmallVec<[AnyElement; 4]>,
212}
213
214impl ToolbarGroup {
215    pub fn new(id: impl Into<ElementId>) -> Self {
216        Self {
217            base: div().id(id.into()),
218            style: StyleRefinement::default(),
219            label: None,
220            children: SmallVec::new(),
221        }
222    }
223
224    /// Sets the accessible name announced for the group, e.g. "History".
225    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
226        self.label = Some(label.into());
227        self
228    }
229}
230
231impl Styled for ToolbarGroup {
232    fn style(&mut self) -> &mut StyleRefinement {
233        &mut self.style
234    }
235}
236
237impl ParentElement for ToolbarGroup {
238    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
239        self.children.extend(elements);
240    }
241}
242
243impl InteractiveElement for ToolbarGroup {
244    fn interactivity(&mut self) -> &mut Interactivity {
245        self.base.interactivity()
246    }
247}
248
249impl StatefulInteractiveElement for ToolbarGroup {}
250
251impl RenderOnce for ToolbarGroup {
252    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
253        // A group is one inline segment of the bar, so its children flow
254        // along the row and center on the bar's cross axis; the same neutral
255        // geometry `Tab` applies. Spacing between items is the caller's
256        // (matching the bar's own gap).
257        self.base
258            .flex()
259            .items_center()
260            .role(Role::Group)
261            .when_some(self.label, |this, label| this.aria_label(label))
262            .children(self.children)
263            .refine_style(&self.style)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_toolbar_builder() {
273        let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
274
275        assert!(toolbar.disabled);
276        assert_eq!(toolbar.children.len(), 1);
277    }
278
279    #[test]
280    fn test_toolbar_defaults() {
281        let toolbar = Toolbar::new("toolbar");
282
283        assert!(!toolbar.disabled);
284        assert!(toolbar.children.is_empty());
285    }
286
287    #[test]
288    fn test_toolbar_group_builder() {
289        let group = ToolbarGroup::new("history-group")
290            .label("History")
291            .child(div())
292            .child(div());
293
294        assert_eq!(group.label.as_deref(), Some("History"));
295        assert_eq!(group.children.len(), 2);
296    }
297
298    #[cfg(test)]
299    mod behavior {
300        use super::*;
301        use gpui::{
302            Context, Element as _, FocusHandle, Render, TestAppContext, VisualTestContext, canvas,
303            px,
304        };
305        use std::sync::{Arc, Mutex};
306
307        struct NavHarness {
308            items: [FocusHandle; 3],
309        }
310
311        impl Render for NavHarness {
312            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
313                let [first, second, third] = &self.items;
314                Toolbar::new("nav-toolbar")
315                    .child(div().id("first").size(px(20.)).track_focus(first))
316                    .child(div().id("second").size(px(20.)).track_focus(second))
317                    .child(div().id("third").size(px(20.)).track_focus(third))
318            }
319        }
320
321        fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
322            let (state, cx) = cx.add_window_view(move |window, cx| {
323                let items = [
324                    cx.focus_handle().tab_stop(true),
325                    cx.focus_handle().tab_stop(true),
326                    cx.focus_handle().tab_stop(true),
327                ];
328                items[0].focus(window, cx);
329                NavHarness { items }
330            });
331            let items = state.read_with(cx, |harness, _| harness.items.clone());
332            cx.update(|window, cx| {
333                window.draw(cx).clear(cx);
334            });
335            (items, cx)
336        }
337
338        fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
339            cx.update(|window, _| {
340                assert!(
341                    expected.is_focused(window),
342                    "expected {label} to be focused"
343                );
344            });
345        }
346
347        #[gpui::test]
348        fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
349            let ([first, second, third], cx) = harness(cx);
350
351            cx.simulate_keystrokes("right");
352            assert_focused(cx, &second, "second");
353            cx.simulate_keystrokes("right");
354            assert_focused(cx, &third, "third");
355
356            // Wrapping: past the last item, focus returns to the first.
357            cx.simulate_keystrokes("right");
358            assert_focused(cx, &first, "first (wrapped)");
359
360            cx.simulate_keystrokes("left");
361            assert_focused(cx, &third, "third (wrapped back)");
362        }
363
364        #[gpui::test]
365        fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
366            struct SingleHarness {
367                item: FocusHandle,
368            }
369
370            impl Render for SingleHarness {
371                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
372                    let item = &self.item;
373                    Toolbar::new("single-toolbar")
374                        .child(div().id("only").size(px(20.)).track_focus(item))
375                }
376            }
377
378            let (state, cx) = cx.add_window_view(|window, cx| {
379                let item = cx.focus_handle().tab_stop(true);
380                item.focus(window, cx);
381                SingleHarness { item }
382            });
383            let item = state.read_with(cx, |harness, _| harness.item.clone());
384            cx.update(|window, cx| {
385                window.draw(cx).clear(cx);
386            });
387
388            cx.simulate_keystrokes("right left right");
389            assert_focused(cx, &item, "the only item");
390        }
391
392        #[gpui::test]
393        fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
394            type Captured = Arc<Mutex<Option<accesskit::Node>>>;
395
396            struct Probe(Captured);
397
398            impl Render for Probe {
399                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
400                    let captured = self.0.clone();
401                    canvas(
402                        move |_, window, cx| {
403                            let mut node = accesskit::Node::new(Role::Group);
404                            ToolbarGroup::new("history")
405                                .label("History")
406                                .child(div().size(px(20.)))
407                                .render(window, cx)
408                                .into_element()
409                                .write_a11y_info(&mut node);
410                            *captured.lock().unwrap() = Some(node);
411                        },
412                        |_, _, _, _| {},
413                    )
414                }
415            }
416
417            let captured: Captured = Arc::new(Mutex::new(None));
418            let result = captured.clone();
419            let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
420            cx.update(|window, cx| window.draw(cx).clear(cx));
421            let node = result.lock().unwrap().take().unwrap();
422
423            assert_eq!(node.role(), Role::Group);
424            assert_eq!(node.label(), Some("History"));
425        }
426
427        #[gpui::test]
428        fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
429            struct DisabledHarness {
430                items: [FocusHandle; 2],
431            }
432
433            impl Render for DisabledHarness {
434                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
435                    let [first, second] = &self.items;
436                    Toolbar::new("disabled-toolbar")
437                        .disabled(true)
438                        .child(div().id("first").size(px(20.)).track_focus(first))
439                        .child(div().id("second").size(px(20.)).track_focus(second))
440                }
441            }
442
443            let (state, cx) = cx.add_window_view(|window, cx| {
444                let items = [
445                    cx.focus_handle().tab_stop(true),
446                    cx.focus_handle().tab_stop(true),
447                ];
448                items[0].focus(window, cx);
449                DisabledHarness { items }
450            });
451            let items = state.read_with(cx, |harness, _| harness.items.clone());
452            cx.update(|window, cx| {
453                window.draw(cx).clear(cx);
454            });
455
456            cx.simulate_keystrokes("right");
457            assert_focused(cx, &items[0], "first (disabled toolbar)");
458        }
459    }
460}