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 _, TestSupportExt as _};
10
11/// An unstyled container that groups a set of controls and owns roving
12/// keyboard focus among them.
13///
14/// This is the behavior primitive behind a styled toolbar. The container
15/// exposes `Toolbar` semantics to assistive technology (`Role::Toolbar` with
16/// an orientation) and moves focus between its focusable descendants with the
17/// arrow keys, so applications do not have to reimplement the roving-focus
18/// contract per toolbar. It works with any focusable children — buttons,
19/// menu triggers, inputs — because traversal walks the rendered tab stops
20/// and constrains them to this subtree, mirroring how `Root` constrains
21/// focus-trap cycling.
22///
23/// Keyboard contract:
24///
25/// - Left and Right move focus to the previous or next focusable descendant,
26///   wrapping around at either end (the same default as Base UI's `loopFocus`).
27/// - When `disabled`, the arrow keys do nothing. Hosted controls must be
28///   disabled by their owner; the flag only suppresses the toolbar's own
29///   navigation.
30///
31/// The container is not itself a tab stop, so ordinary `Tab` traversal enters
32/// and leaves the toolbar through its items, matching the ARIA toolbar
33/// pattern.
34///
35/// An input hosted inside the toolbar keeps its own arrow-key behavior: text
36/// inputs consume the arrow keys for caret movement before the toolbar sees
37/// them. Place inputs at the trailing end of a horizontal toolbar, as the
38/// Base UI Toolbar recommends.
39#[derive(IntoElement)]
40pub struct Toolbar {
41    id: ElementId,
42    base: crate::ObservedElement<Stateful<Div>>,
43    style: StyleRefinement,
44    disabled: bool,
45    children: SmallVec<[AnyElement; 4]>,
46}
47
48impl Toolbar {
49    pub fn new(id: impl Into<ElementId>) -> Self {
50        let id = id.into();
51        Self {
52            base: div().id(id.clone()).test_support(),
53            style: StyleRefinement::default(),
54            disabled: false,
55            children: SmallVec::new(),
56            id,
57        }
58    }
59
60    /// Disables the toolbar's own keyboard navigation. Hosted controls are
61    /// not automatically disabled.
62    pub fn disabled(mut self, disabled: bool) -> Self {
63        self.disabled = disabled;
64        self
65    }
66}
67
68fn step_focus(window: &mut Window, cx: &mut App, forward: bool) {
69    if forward {
70        window.focus_next(cx);
71    } else {
72        window.focus_prev(cx);
73    }
74}
75
76/// Move focus to the next (or previous) focusable element inside `container`.
77///
78/// Walk the window's tab order in the asked direction, passing over stops
79/// outside the container, and land on the first stop inside it. The tab order
80/// wraps around the window, so past the container's last stop the walk comes
81/// back in at its first however many stops the rest of the window holds, and a
82/// stop that a tab index places between two of the container's own is passed
83/// over like any other. GPUI's tab order can cycle short of the whole window,
84/// so the walk ends at the first repeated stop and focus goes back where it
85/// started: a container with no other reachable stop keeps focus where it is.
86fn move_focus(container: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
87    let Some(start) = window.focused(cx) else {
88        return;
89    };
90
91    let mut visited = vec![start.clone()];
92    loop {
93        step_focus(window, cx, forward);
94        let Some(focused) = window.focused(cx) else {
95            break;
96        };
97        if visited.contains(&focused) {
98            break;
99        }
100        if container.contains_focused(window, cx) {
101            return;
102        }
103        visited.push(focused);
104    }
105    window.focus(&start, cx);
106}
107
108fn handle_key_down(
109    disabled: bool,
110    container: &FocusHandle,
111    event: &KeyDownEvent,
112    window: &mut Window,
113    cx: &mut App,
114) {
115    if disabled {
116        return;
117    }
118
119    let forward = match event.keystroke.key.as_str() {
120        "left" => Some(false),
121        "right" => Some(true),
122        _ => None,
123    };
124
125    if let Some(forward) = forward {
126        move_focus(container, forward, window, cx);
127        cx.stop_propagation();
128    }
129}
130
131impl Styled for Toolbar {
132    fn style(&mut self) -> &mut StyleRefinement {
133        &mut self.style
134    }
135}
136
137impl ParentElement for Toolbar {
138    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
139        self.children.extend(elements);
140    }
141}
142
143impl InteractiveElement for Toolbar {
144    fn interactivity(&mut self) -> &mut Interactivity {
145        self.base.interactivity()
146    }
147}
148
149impl StatefulInteractiveElement for Toolbar {}
150
151impl RenderOnce for Toolbar {
152    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
153        let Self {
154            base,
155            style,
156            disabled,
157            children,
158            ..
159        } = self;
160
161        // The handle must survive across frames so containment checks and the
162        // key handler refer to the same dispatch-tree node; button.rs uses
163        // the same keyed-state pattern for its own focus handle.
164        let focus_handle = window
165            .use_keyed_state(self.id.clone(), cx, |_, cx| {
166                cx.focus_handle().tab_stop(false)
167            })
168            .read(cx)
169            .clone();
170        let key_handler = {
171            let focus_handle = focus_handle.clone();
172            move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| {
173                handle_key_down(disabled, &focus_handle, event, window, cx);
174            }
175        };
176
177        base.track_focus(&focus_handle)
178            .role(Role::Toolbar)
179            .aria_orientation(accesskit::Orientation::Horizontal)
180            .on_key_down(key_handler)
181            // The handle exists for containment checks only. Without this, a
182            // press on blank space or non-focusable content would focus the
183            // toolbar itself and take focus away from the rest of the window.
184            // Focusable items handle the press first and keep their focus.
185            .on_any_mouse_down(|_, window, _| window.prevent_default())
186            .children(children)
187            .refine_style(&style)
188    }
189}
190
191/// A semantic subgroup of items within a [`Toolbar`].
192///
193/// The group carries no behavior or styling of its own: the surrounding
194/// toolbar's roving arrow-key focus traverses its items exactly like the
195/// toolbar's direct children, because containment follows the element tree.
196/// Its value is structure — assistive technology announces the group and its
197/// accessible name, so a run of related controls reads as one unit ("Undo",
198/// "Redo" inside a "History" group).
199///
200/// Unlike Base UI's `Toolbar.Group`, the group cannot disable its children.
201/// That API propagates through React context into Base UI's own button
202/// primitives; GPUI composition offers no equivalent for arbitrary children,
203/// and the platform a11y layer exposes no disabled state for a container
204/// node. Disabling the hosted controls is the group owner's job.
205#[derive(IntoElement)]
206pub struct ToolbarGroup {
207    base: Stateful<Div>,
208    style: StyleRefinement,
209    label: Option<SharedString>,
210    children: SmallVec<[AnyElement; 4]>,
211}
212
213impl ToolbarGroup {
214    pub fn new(id: impl Into<ElementId>) -> Self {
215        Self {
216            base: div().id(id.into()),
217            style: StyleRefinement::default(),
218            label: None,
219            children: SmallVec::new(),
220        }
221    }
222
223    /// Sets the accessible name announced for the group, e.g. "History".
224    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
225        self.label = Some(label.into());
226        self
227    }
228}
229
230impl Styled for ToolbarGroup {
231    fn style(&mut self) -> &mut StyleRefinement {
232        &mut self.style
233    }
234}
235
236impl ParentElement for ToolbarGroup {
237    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
238        self.children.extend(elements);
239    }
240}
241
242impl InteractiveElement for ToolbarGroup {
243    fn interactivity(&mut self) -> &mut Interactivity {
244        self.base.interactivity()
245    }
246}
247
248impl StatefulInteractiveElement for ToolbarGroup {}
249
250impl RenderOnce for ToolbarGroup {
251    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
252        // A group is one inline segment of the bar, so its children flow
253        // along the row and center on the bar's cross axis; the same neutral
254        // geometry `Tab` applies. Spacing between items is the caller's
255        // (matching the bar's own gap).
256        self.base
257            .flex()
258            .items_center()
259            .role(Role::Group)
260            .when_some(self.label, |this, label| this.aria_label(label))
261            .children(self.children)
262            .refine_style(&self.style)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_toolbar_builder() {
272        let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
273
274        assert!(toolbar.disabled);
275        assert_eq!(toolbar.children.len(), 1);
276    }
277
278    #[test]
279    fn test_toolbar_defaults() {
280        let toolbar = Toolbar::new("toolbar");
281
282        assert!(!toolbar.disabled);
283        assert!(toolbar.children.is_empty());
284    }
285
286    #[test]
287    fn test_toolbar_group_builder() {
288        let group = ToolbarGroup::new("history-group")
289            .label("History")
290            .child(div())
291            .child(div());
292
293        assert_eq!(group.label.as_deref(), Some("History"));
294        assert_eq!(group.children.len(), 2);
295    }
296
297    #[cfg(test)]
298    mod behavior {
299        use super::*;
300        use gpui::{
301            Context, Element as _, FocusHandle, Modifiers, Render, TestAppContext,
302            VisualTestContext, canvas, point, px,
303        };
304        use std::sync::{Arc, Mutex};
305
306        struct NavHarness {
307            items: [FocusHandle; 3],
308        }
309
310        impl Render for NavHarness {
311            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
312                let [first, second, third] = &self.items;
313                Toolbar::new("nav-toolbar")
314                    .child(div().id("first").size(px(20.)).track_focus(first))
315                    .child(div().id("second").size(px(20.)).track_focus(second))
316                    .child(div().id("third").size(px(20.)).track_focus(third))
317            }
318        }
319
320        fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
321            let (state, cx) = cx.add_window_view(move |window, cx| {
322                let items = [
323                    cx.focus_handle().tab_stop(true),
324                    cx.focus_handle().tab_stop(true),
325                    cx.focus_handle().tab_stop(true),
326                ];
327                items[0].focus(window, cx);
328                NavHarness { items }
329            });
330            let items = state.read_with(cx, |harness, _| harness.items.clone());
331            cx.update(|window, cx| {
332                window.draw(cx).clear(cx);
333            });
334            (items, cx)
335        }
336
337        fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
338            cx.update(|window, _| {
339                assert!(
340                    expected.is_focused(window),
341                    "expected {label} to be focused"
342                );
343            });
344        }
345
346        #[gpui::test]
347        fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
348            let ([first, second, third], cx) = harness(cx);
349
350            cx.simulate_keystrokes("right");
351            assert_focused(cx, &second, "second");
352            cx.simulate_keystrokes("right");
353            assert_focused(cx, &third, "third");
354
355            // Wrapping: past the last item, focus returns to the first.
356            cx.simulate_keystrokes("right");
357            assert_focused(cx, &first, "first (wrapped)");
358
359            cx.simulate_keystrokes("left");
360            assert_focused(cx, &third, "third (wrapped back)");
361        }
362
363        #[gpui::test]
364        fn arrow_keys_wrap_inside_the_toolbar_among_many_page_stops(cx: &mut TestAppContext) {
365            // More than the hundred hops the search used to give up after.
366            const STOPS: usize = 120;
367
368            struct BusyPage {
369                before: Vec<FocusHandle>,
370                items: [FocusHandle; 3],
371                after: Vec<FocusHandle>,
372            }
373
374            impl Render for BusyPage {
375                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
376                    let [first, second, third] = &self.items;
377                    div()
378                        .flex()
379                        .flex_col()
380                        .children(self.before.iter().enumerate().map(|(at, handle)| {
381                            div().id(("before", at)).size(px(1.)).track_focus(handle)
382                        }))
383                        .child(
384                            Toolbar::new("busy-toolbar")
385                                .child(div().id("first").size(px(20.)).track_focus(first))
386                                .child(div().id("second").size(px(20.)).track_focus(second))
387                                .child(div().id("third").size(px(20.)).track_focus(third)),
388                        )
389                        .children(self.after.iter().enumerate().map(|(at, handle)| {
390                            div().id(("after", at)).size(px(1.)).track_focus(handle)
391                        }))
392                }
393            }
394
395            let (state, cx) = cx.add_window_view(|window, cx| {
396                let stop = |cx: &mut Context<BusyPage>| cx.focus_handle().tab_stop(true);
397                let page = BusyPage {
398                    before: (0..STOPS).map(|_| stop(cx)).collect(),
399                    items: [stop(cx), stop(cx), stop(cx)],
400                    after: (0..STOPS).map(|_| stop(cx)).collect(),
401                };
402                page.items[0].focus(window, cx);
403                page
404            });
405            let [first, second, third] = state.read_with(cx, |page, _| page.items.clone());
406            cx.update(|window, cx| {
407                window.draw(cx).clear(cx);
408            });
409
410            cx.simulate_keystrokes("left");
411            assert_focused(cx, &third, "third (Left from the first)");
412            cx.simulate_keystrokes("right");
413            assert_focused(cx, &first, "first (Right from the last)");
414            cx.simulate_keystrokes("right");
415            assert_focused(cx, &second, "second (one step right)");
416            cx.simulate_keystrokes("left");
417            assert_focused(cx, &first, "first (one step left)");
418        }
419
420        #[gpui::test]
421        fn arrow_keys_wrap_across_more_than_a_hundred_items(cx: &mut TestAppContext) {
422            const ITEMS: usize = 120;
423
424            struct LargeToolbar {
425                outside: FocusHandle,
426                items: Vec<FocusHandle>,
427            }
428
429            impl Render for LargeToolbar {
430                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
431                    div()
432                        .child(div().id("outside").size(px(1.)).track_focus(&self.outside))
433                        .child(Toolbar::new("large-toolbar").children(
434                            self.items.iter().enumerate().map(|(at, handle)| {
435                                div().id(("item", at)).size(px(1.)).track_focus(handle)
436                            }),
437                        ))
438                }
439            }
440
441            let (state, cx) = cx.add_window_view(|window, cx| {
442                let page = LargeToolbar {
443                    outside: cx.focus_handle().tab_stop(true),
444                    items: (0..ITEMS)
445                        .map(|_| cx.focus_handle().tab_stop(true))
446                        .collect(),
447                };
448                page.items[0].focus(window, cx);
449                page
450            });
451            let items = state.read_with(cx, |page, _| page.items.clone());
452            cx.update(|window, cx| {
453                window.draw(cx).clear(cx);
454            });
455
456            cx.simulate_keystrokes("left");
457            assert_focused(cx, &items[ITEMS - 1], "the last item (Left from the first)");
458            cx.simulate_keystrokes("right");
459            assert_focused(cx, &items[0], "the first item (Right from the last)");
460        }
461
462        #[gpui::test]
463        fn arrow_keys_pass_over_stops_ordered_between_the_items(cx: &mut TestAppContext) {
464            struct InterleavedPage {
465                items: [FocusHandle; 3],
466                outside: [FocusHandle; 2],
467            }
468
469            impl Render for InterleavedPage {
470                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
471                    let [first, second, third] = &self.items;
472                    let [before, after] = &self.outside;
473                    div()
474                        .child(
475                            Toolbar::new("interleaved-toolbar")
476                                .child(div().id("first").size(px(20.)).track_focus(first))
477                                .child(div().id("second").size(px(20.)).track_focus(second))
478                                .child(div().id("third").size(px(20.)).track_focus(third)),
479                        )
480                        .child(div().id("before").size(px(20.)).track_focus(before))
481                        .child(div().id("after").size(px(20.)).track_focus(after))
482                }
483            }
484
485            // The tab order runs first, before, second, after, third: each page stop's
486            // tab index puts it between two of the toolbar's items.
487            let (state, cx) = cx.add_window_view(|window, cx| {
488                let stop = |index, cx: &mut Context<InterleavedPage>| {
489                    cx.focus_handle().tab_stop(true).tab_index(index)
490                };
491                let page = InterleavedPage {
492                    items: [stop(1, cx), stop(3, cx), stop(5, cx)],
493                    outside: [stop(2, cx), stop(4, cx)],
494                };
495                page.items[0].focus(window, cx);
496                page
497            });
498            let [first, second, third] = state.read_with(cx, |page, _| page.items.clone());
499            cx.update(|window, cx| {
500                window.draw(cx).clear(cx);
501            });
502
503            cx.simulate_keystrokes("right");
504            assert_focused(cx, &second, "second (Right from the first)");
505            cx.simulate_keystrokes("right");
506            assert_focused(cx, &third, "third (Right from the second)");
507            cx.simulate_keystrokes("right");
508            assert_focused(cx, &first, "first (Right from the last)");
509            cx.simulate_keystrokes("left");
510            assert_focused(cx, &third, "third (Left from the first)");
511        }
512
513        #[gpui::test]
514        fn arrow_keys_keep_focus_when_no_other_item_is_a_tab_stop(cx: &mut TestAppContext) {
515            struct Unreachable {
516                item: FocusHandle,
517                outside: [FocusHandle; 2],
518            }
519
520            impl Render for Unreachable {
521                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
522                    let [before, after] = &self.outside;
523                    div()
524                        .child(div().id("before").size(px(20.)).track_focus(before))
525                        .child(
526                            Toolbar::new("unreachable-toolbar")
527                                .child(div().id("item").size(px(20.)).track_focus(&self.item)),
528                        )
529                        .child(div().id("after").size(px(20.)).track_focus(after))
530                }
531            }
532
533            // The focused item is not a tab stop, so the walk never comes back to
534            // it: only the page's own stops repeat.
535            let (state, cx) = cx.add_window_view(|window, cx| {
536                let page = Unreachable {
537                    item: cx.focus_handle(),
538                    outside: [
539                        cx.focus_handle().tab_stop(true),
540                        cx.focus_handle().tab_stop(true),
541                    ],
542                };
543                page.item.focus(window, cx);
544                page
545            });
546            let item = state.read_with(cx, |page, _| page.item.clone());
547            cx.update(|window, cx| {
548                window.draw(cx).clear(cx);
549            });
550
551            cx.simulate_keystrokes("right left");
552            assert_focused(cx, &item, "the item (no other stop in the toolbar)");
553        }
554
555        #[gpui::test]
556        fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
557            struct SingleHarness {
558                item: FocusHandle,
559            }
560
561            impl Render for SingleHarness {
562                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
563                    let item = &self.item;
564                    Toolbar::new("single-toolbar")
565                        .child(div().id("only").size(px(20.)).track_focus(item))
566                }
567            }
568
569            let (state, cx) = cx.add_window_view(|window, cx| {
570                let item = cx.focus_handle().tab_stop(true);
571                item.focus(window, cx);
572                SingleHarness { item }
573            });
574            let item = state.read_with(cx, |harness, _| harness.item.clone());
575            cx.update(|window, cx| {
576                window.draw(cx).clear(cx);
577            });
578
579            cx.simulate_keystrokes("right left right");
580            assert_focused(cx, &item, "the only item");
581        }
582
583        #[gpui::test]
584        fn snapshot_reports_the_toolbar_role_label_and_focus(cx: &mut gpui::TestAppContext) {
585            struct Labelled(FocusHandle);
586
587            impl Render for Labelled {
588                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
589                    Toolbar::new("formatting")
590                        .aria_label("Formatting")
591                        .child(div().id("bold").size(px(20.)).track_focus(&self.0))
592                }
593            }
594
595            let (_, cx) = cx.add_window_view(|window, cx| {
596                let item = cx.focus_handle().tab_stop(true);
597                item.focus(window, cx);
598                Labelled(item)
599            });
600            cx.update(|window, cx| {
601                window.draw(cx).clear(cx);
602                let snapshot = crate::test_support::find(window, &[], &"formatting".into())
603                    .expect("the toolbar is observed");
604                assert_eq!(snapshot.role(), Some(Role::Toolbar));
605                assert_eq!(snapshot.label(), Some("Formatting"));
606                // The toolbar's handle is not a tab stop; it reports focus
607                // while one of its items holds it.
608                assert_eq!(snapshot.focused(), Some(true));
609            });
610        }
611
612        #[gpui::test]
613        fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
614            type Captured = Arc<Mutex<Option<accesskit::Node>>>;
615
616            struct Probe(Captured);
617
618            impl Render for Probe {
619                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
620                    let captured = self.0.clone();
621                    canvas(
622                        move |_, window, cx| {
623                            let mut node = accesskit::Node::new(Role::Group);
624                            ToolbarGroup::new("history")
625                                .label("History")
626                                .child(div().size(px(20.)))
627                                .render(window, cx)
628                                .into_element()
629                                .write_a11y_info(&mut node);
630                            *captured.lock().unwrap() = Some(node);
631                        },
632                        |_, _, _, _| {},
633                    )
634                }
635            }
636
637            let captured: Captured = Arc::new(Mutex::new(None));
638            let result = captured.clone();
639            let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
640            cx.update(|window, cx| window.draw(cx).clear(cx));
641            let node = result.lock().unwrap().take().unwrap();
642
643            assert_eq!(node.role(), Role::Group);
644            assert_eq!(node.label(), Some("History"));
645        }
646
647        #[gpui::test]
648        fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
649            struct DisabledHarness {
650                items: [FocusHandle; 2],
651            }
652
653            impl Render for DisabledHarness {
654                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
655                    let [first, second] = &self.items;
656                    Toolbar::new("disabled-toolbar")
657                        .disabled(true)
658                        .child(div().id("first").size(px(20.)).track_focus(first))
659                        .child(div().id("second").size(px(20.)).track_focus(second))
660                }
661            }
662
663            let (state, cx) = cx.add_window_view(|window, cx| {
664                let items = [
665                    cx.focus_handle().tab_stop(true),
666                    cx.focus_handle().tab_stop(true),
667                ];
668                items[0].focus(window, cx);
669                DisabledHarness { items }
670            });
671            let items = state.read_with(cx, |harness, _| harness.items.clone());
672            cx.update(|window, cx| {
673                window.draw(cx).clear(cx);
674            });
675
676            cx.simulate_keystrokes("right");
677            assert_focused(cx, &items[0], "first (disabled toolbar)");
678        }
679
680        #[gpui::test]
681        fn clicking_blank_toolbar_space_keeps_focus_outside(cx: &mut gpui::TestAppContext) {
682            struct ClickHarness {
683                outside: FocusHandle,
684                item: FocusHandle,
685            }
686
687            impl Render for ClickHarness {
688                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
689                    div()
690                        .child(div().id("outside").size(px(20.)).track_focus(&self.outside))
691                        .child(
692                            Toolbar::new("click-toolbar")
693                                .flex()
694                                .w(px(100.))
695                                .h(px(20.))
696                                .child(div().id("item").size(px(20.)).track_focus(&self.item)),
697                        )
698                }
699            }
700
701            let (state, cx) = cx.add_window_view(|window, cx| {
702                let outside = cx.focus_handle().tab_stop(true);
703                outside.focus(window, cx);
704                ClickHarness {
705                    outside,
706                    item: cx.focus_handle().tab_stop(true),
707                }
708            });
709            let (outside, item) = state.read_with(cx, |harness, _| {
710                (harness.outside.clone(), harness.item.clone())
711            });
712            cx.update(|window, cx| {
713                window.draw(cx).clear(cx);
714            });
715
716            // Blank space to the right of the item: focus stays where it was.
717            cx.simulate_click(point(px(80.), px(30.)), Modifiers::default());
718            assert_focused(cx, &outside, "outside (blank toolbar click)");
719
720            // A focusable item still takes focus when clicked.
721            cx.simulate_click(point(px(10.), px(30.)), Modifiers::default());
722            assert_focused(cx, &item, "item (clicked)");
723        }
724    }
725}