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            // The handle exists for containment checks only. Without this, a
188            // press on blank space or non-focusable content would focus the
189            // toolbar itself and take focus away from the rest of the window.
190            // Focusable items handle the press first and keep their focus.
191            .on_any_mouse_down(|_, window, _| window.prevent_default())
192            .children(children)
193            .refine_style(&style)
194    }
195}
196
197/// A semantic subgroup of items within a [`Toolbar`].
198///
199/// The group carries no behavior or styling of its own: the surrounding
200/// toolbar's roving arrow-key focus traverses its items exactly like the
201/// toolbar's direct children, because containment follows the element tree.
202/// Its value is structure — assistive technology announces the group and its
203/// accessible name, so a run of related controls reads as one unit ("Undo",
204/// "Redo" inside a "History" group).
205///
206/// Unlike Base UI's `Toolbar.Group`, the group cannot disable its children.
207/// That API propagates through React context into Base UI's own button
208/// primitives; GPUI composition offers no equivalent for arbitrary children,
209/// and the platform a11y layer exposes no disabled state for a container
210/// node. Disabling the hosted controls is the group owner's job.
211#[derive(IntoElement)]
212pub struct ToolbarGroup {
213    base: Stateful<Div>,
214    style: StyleRefinement,
215    label: Option<SharedString>,
216    children: SmallVec<[AnyElement; 4]>,
217}
218
219impl ToolbarGroup {
220    pub fn new(id: impl Into<ElementId>) -> Self {
221        Self {
222            base: div().id(id.into()),
223            style: StyleRefinement::default(),
224            label: None,
225            children: SmallVec::new(),
226        }
227    }
228
229    /// Sets the accessible name announced for the group, e.g. "History".
230    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
231        self.label = Some(label.into());
232        self
233    }
234}
235
236impl Styled for ToolbarGroup {
237    fn style(&mut self) -> &mut StyleRefinement {
238        &mut self.style
239    }
240}
241
242impl ParentElement for ToolbarGroup {
243    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
244        self.children.extend(elements);
245    }
246}
247
248impl InteractiveElement for ToolbarGroup {
249    fn interactivity(&mut self) -> &mut Interactivity {
250        self.base.interactivity()
251    }
252}
253
254impl StatefulInteractiveElement for ToolbarGroup {}
255
256impl RenderOnce for ToolbarGroup {
257    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
258        // A group is one inline segment of the bar, so its children flow
259        // along the row and center on the bar's cross axis; the same neutral
260        // geometry `Tab` applies. Spacing between items is the caller's
261        // (matching the bar's own gap).
262        self.base
263            .flex()
264            .items_center()
265            .role(Role::Group)
266            .when_some(self.label, |this, label| this.aria_label(label))
267            .children(self.children)
268            .refine_style(&self.style)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_toolbar_builder() {
278        let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
279
280        assert!(toolbar.disabled);
281        assert_eq!(toolbar.children.len(), 1);
282    }
283
284    #[test]
285    fn test_toolbar_defaults() {
286        let toolbar = Toolbar::new("toolbar");
287
288        assert!(!toolbar.disabled);
289        assert!(toolbar.children.is_empty());
290    }
291
292    #[test]
293    fn test_toolbar_group_builder() {
294        let group = ToolbarGroup::new("history-group")
295            .label("History")
296            .child(div())
297            .child(div());
298
299        assert_eq!(group.label.as_deref(), Some("History"));
300        assert_eq!(group.children.len(), 2);
301    }
302
303    #[cfg(test)]
304    mod behavior {
305        use super::*;
306        use gpui::{
307            Context, Element as _, FocusHandle, Modifiers, Render, TestAppContext,
308            VisualTestContext, canvas, point, px,
309        };
310        use std::sync::{Arc, Mutex};
311
312        struct NavHarness {
313            items: [FocusHandle; 3],
314        }
315
316        impl Render for NavHarness {
317            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
318                let [first, second, third] = &self.items;
319                Toolbar::new("nav-toolbar")
320                    .child(div().id("first").size(px(20.)).track_focus(first))
321                    .child(div().id("second").size(px(20.)).track_focus(second))
322                    .child(div().id("third").size(px(20.)).track_focus(third))
323            }
324        }
325
326        fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
327            let (state, cx) = cx.add_window_view(move |window, cx| {
328                let items = [
329                    cx.focus_handle().tab_stop(true),
330                    cx.focus_handle().tab_stop(true),
331                    cx.focus_handle().tab_stop(true),
332                ];
333                items[0].focus(window, cx);
334                NavHarness { items }
335            });
336            let items = state.read_with(cx, |harness, _| harness.items.clone());
337            cx.update(|window, cx| {
338                window.draw(cx).clear(cx);
339            });
340            (items, cx)
341        }
342
343        fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
344            cx.update(|window, _| {
345                assert!(
346                    expected.is_focused(window),
347                    "expected {label} to be focused"
348                );
349            });
350        }
351
352        #[gpui::test]
353        fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
354            let ([first, second, third], cx) = harness(cx);
355
356            cx.simulate_keystrokes("right");
357            assert_focused(cx, &second, "second");
358            cx.simulate_keystrokes("right");
359            assert_focused(cx, &third, "third");
360
361            // Wrapping: past the last item, focus returns to the first.
362            cx.simulate_keystrokes("right");
363            assert_focused(cx, &first, "first (wrapped)");
364
365            cx.simulate_keystrokes("left");
366            assert_focused(cx, &third, "third (wrapped back)");
367        }
368
369        #[gpui::test]
370        fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
371            struct SingleHarness {
372                item: FocusHandle,
373            }
374
375            impl Render for SingleHarness {
376                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
377                    let item = &self.item;
378                    Toolbar::new("single-toolbar")
379                        .child(div().id("only").size(px(20.)).track_focus(item))
380                }
381            }
382
383            let (state, cx) = cx.add_window_view(|window, cx| {
384                let item = cx.focus_handle().tab_stop(true);
385                item.focus(window, cx);
386                SingleHarness { item }
387            });
388            let item = state.read_with(cx, |harness, _| harness.item.clone());
389            cx.update(|window, cx| {
390                window.draw(cx).clear(cx);
391            });
392
393            cx.simulate_keystrokes("right left right");
394            assert_focused(cx, &item, "the only item");
395        }
396
397        #[gpui::test]
398        fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
399            type Captured = Arc<Mutex<Option<accesskit::Node>>>;
400
401            struct Probe(Captured);
402
403            impl Render for Probe {
404                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
405                    let captured = self.0.clone();
406                    canvas(
407                        move |_, window, cx| {
408                            let mut node = accesskit::Node::new(Role::Group);
409                            ToolbarGroup::new("history")
410                                .label("History")
411                                .child(div().size(px(20.)))
412                                .render(window, cx)
413                                .into_element()
414                                .write_a11y_info(&mut node);
415                            *captured.lock().unwrap() = Some(node);
416                        },
417                        |_, _, _, _| {},
418                    )
419                }
420            }
421
422            let captured: Captured = Arc::new(Mutex::new(None));
423            let result = captured.clone();
424            let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
425            cx.update(|window, cx| window.draw(cx).clear(cx));
426            let node = result.lock().unwrap().take().unwrap();
427
428            assert_eq!(node.role(), Role::Group);
429            assert_eq!(node.label(), Some("History"));
430        }
431
432        #[gpui::test]
433        fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
434            struct DisabledHarness {
435                items: [FocusHandle; 2],
436            }
437
438            impl Render for DisabledHarness {
439                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
440                    let [first, second] = &self.items;
441                    Toolbar::new("disabled-toolbar")
442                        .disabled(true)
443                        .child(div().id("first").size(px(20.)).track_focus(first))
444                        .child(div().id("second").size(px(20.)).track_focus(second))
445                }
446            }
447
448            let (state, cx) = cx.add_window_view(|window, cx| {
449                let items = [
450                    cx.focus_handle().tab_stop(true),
451                    cx.focus_handle().tab_stop(true),
452                ];
453                items[0].focus(window, cx);
454                DisabledHarness { items }
455            });
456            let items = state.read_with(cx, |harness, _| harness.items.clone());
457            cx.update(|window, cx| {
458                window.draw(cx).clear(cx);
459            });
460
461            cx.simulate_keystrokes("right");
462            assert_focused(cx, &items[0], "first (disabled toolbar)");
463        }
464
465        #[gpui::test]
466        fn clicking_blank_toolbar_space_keeps_focus_outside(cx: &mut gpui::TestAppContext) {
467            struct ClickHarness {
468                outside: FocusHandle,
469                item: FocusHandle,
470            }
471
472            impl Render for ClickHarness {
473                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
474                    div()
475                        .child(div().id("outside").size(px(20.)).track_focus(&self.outside))
476                        .child(
477                            Toolbar::new("click-toolbar")
478                                .flex()
479                                .w(px(100.))
480                                .h(px(20.))
481                                .child(div().id("item").size(px(20.)).track_focus(&self.item)),
482                        )
483                }
484            }
485
486            let (state, cx) = cx.add_window_view(|window, cx| {
487                let outside = cx.focus_handle().tab_stop(true);
488                outside.focus(window, cx);
489                ClickHarness {
490                    outside,
491                    item: cx.focus_handle().tab_stop(true),
492                }
493            });
494            let (outside, item) = state.read_with(cx, |harness, _| {
495                (harness.outside.clone(), harness.item.clone())
496            });
497            cx.update(|window, cx| {
498                window.draw(cx).clear(cx);
499            });
500
501            // Blank space to the right of the item: focus stays where it was.
502            cx.simulate_click(point(px(80.), px(30.)), Modifiers::default());
503            assert_focused(cx, &outside, "outside (blank toolbar click)");
504
505            // A focusable item still takes focus when clicked.
506            cx.simulate_click(point(px(10.), px(30.)), Modifiers::default());
507            assert_focused(cx, &item, "item (clicked)");
508        }
509    }
510}