1use gpui::{
2 AnyElement, App, Axis, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
3 IntoElement, 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#[derive(IntoElement)]
42pub struct Toolbar {
43 id: ElementId,
44 base: crate::ObservedElement<Stateful<Div>>,
45 style: StyleRefinement,
46 disabled: bool,
47 axis: Axis,
48 children: SmallVec<[AnyElement; 4]>,
49}
50
51impl Toolbar {
52 pub fn new(id: impl Into<ElementId>) -> Self {
53 let id = id.into();
54 Self {
55 base: div().id(id.clone()).test_support(),
56 style: StyleRefinement::default(),
57 disabled: false,
58 axis: Axis::Horizontal,
59 children: SmallVec::new(),
60 id,
61 }
62 }
63
64 pub fn disabled(mut self, disabled: bool) -> Self {
67 self.disabled = disabled;
68 self
69 }
70
71 pub fn axis(mut self, axis: Axis) -> Self {
74 self.axis = axis;
75 self
76 }
77}
78
79fn step_focus(window: &mut Window, cx: &mut App, forward: bool) {
80 if forward {
81 window.focus_next(cx);
82 } else {
83 window.focus_prev(cx);
84 }
85}
86
87fn move_focus(container: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
98 let Some(start) = window.focused(cx) else {
99 return;
100 };
101
102 let mut visited = vec![start.clone()];
103 loop {
104 step_focus(window, cx, forward);
105 let Some(focused) = window.focused(cx) else {
106 break;
107 };
108 if visited.contains(&focused) {
109 break;
110 }
111 if container.contains_focused(window, cx) {
112 return;
113 }
114 visited.push(focused);
115 }
116 window.focus(&start, cx);
117}
118
119fn handle_key_down(
120 disabled: bool,
121 axis: Axis,
122 container: &FocusHandle,
123 event: &KeyDownEvent,
124 window: &mut Window,
125 cx: &mut App,
126) {
127 if disabled {
128 return;
129 }
130
131 let forward = match (axis, event.keystroke.key.as_str()) {
132 (Axis::Horizontal, "left") | (Axis::Vertical, "up") => Some(false),
133 (Axis::Horizontal, "right") | (Axis::Vertical, "down") => Some(true),
134 _ => None,
135 };
136
137 if let Some(forward) = forward {
138 move_focus(container, forward, window, cx);
139 cx.stop_propagation();
140 }
141}
142
143impl Styled for Toolbar {
144 fn style(&mut self) -> &mut StyleRefinement {
145 &mut self.style
146 }
147}
148
149impl ParentElement for Toolbar {
150 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
151 self.children.extend(elements);
152 }
153}
154
155impl InteractiveElement for Toolbar {
156 fn interactivity(&mut self) -> &mut Interactivity {
157 self.base.interactivity()
158 }
159}
160
161impl StatefulInteractiveElement for Toolbar {}
162
163impl RenderOnce for Toolbar {
164 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
165 let Self {
166 base,
167 style,
168 disabled,
169 axis,
170 children,
171 ..
172 } = self;
173
174 let focus_handle = window
178 .use_keyed_state(self.id.clone(), cx, |_, cx| {
179 cx.focus_handle().tab_stop(false)
180 })
181 .read(cx)
182 .clone();
183 let key_handler = {
184 let focus_handle = focus_handle.clone();
185 move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| {
186 handle_key_down(disabled, axis, &focus_handle, event, window, cx);
187 }
188 };
189
190 base.track_focus(&focus_handle)
191 .role(Role::Toolbar)
192 .aria_orientation(match axis {
193 Axis::Horizontal => accesskit::Orientation::Horizontal,
194 Axis::Vertical => accesskit::Orientation::Vertical,
195 })
196 .on_key_down(key_handler)
197 .on_any_mouse_down(|_, window, _| window.prevent_default())
202 .children(children)
203 .refine_style(&style)
204 }
205}
206
207#[derive(IntoElement)]
222pub struct ToolbarGroup {
223 base: Stateful<Div>,
224 style: StyleRefinement,
225 label: Option<SharedString>,
226 children: SmallVec<[AnyElement; 4]>,
227}
228
229impl ToolbarGroup {
230 pub fn new(id: impl Into<ElementId>) -> Self {
231 Self {
232 base: div().id(id.into()),
233 style: StyleRefinement::default(),
234 label: None,
235 children: SmallVec::new(),
236 }
237 }
238
239 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
241 self.label = Some(label.into());
242 self
243 }
244}
245
246impl Styled for ToolbarGroup {
247 fn style(&mut self) -> &mut StyleRefinement {
248 &mut self.style
249 }
250}
251
252impl ParentElement for ToolbarGroup {
253 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
254 self.children.extend(elements);
255 }
256}
257
258impl InteractiveElement for ToolbarGroup {
259 fn interactivity(&mut self) -> &mut Interactivity {
260 self.base.interactivity()
261 }
262}
263
264impl StatefulInteractiveElement for ToolbarGroup {}
265
266impl RenderOnce for ToolbarGroup {
267 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
268 self.base
273 .flex()
274 .items_center()
275 .role(Role::Group)
276 .when_some(self.label, |this, label| this.aria_label(label))
277 .children(self.children)
278 .refine_style(&self.style)
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_toolbar_builder() {
288 let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
289
290 assert!(toolbar.disabled);
291 assert_eq!(toolbar.children.len(), 1);
292 }
293
294 #[test]
295 fn test_toolbar_defaults() {
296 let toolbar = Toolbar::new("toolbar");
297
298 assert!(!toolbar.disabled);
299 assert_eq!(toolbar.axis, Axis::Horizontal);
300 assert!(toolbar.children.is_empty());
301 assert_eq!(
302 Toolbar::new("toolbar").axis(Axis::Vertical).axis,
303 Axis::Vertical
304 );
305 }
306
307 #[test]
308 fn test_toolbar_group_builder() {
309 let group = ToolbarGroup::new("history-group")
310 .label("History")
311 .child(div())
312 .child(div());
313
314 assert_eq!(group.label.as_deref(), Some("History"));
315 assert_eq!(group.children.len(), 2);
316 }
317
318 #[cfg(test)]
319 mod behavior {
320 use super::*;
321 use gpui::{
322 Context, Element as _, FocusHandle, Modifiers, Render, TestAppContext,
323 VisualTestContext, canvas, point, px,
324 };
325 use std::sync::{Arc, Mutex};
326
327 struct NavHarness {
328 items: [FocusHandle; 3],
329 }
330
331 impl Render for NavHarness {
332 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
333 let [first, second, third] = &self.items;
334 Toolbar::new("nav-toolbar")
335 .child(div().id("first").size(px(20.)).track_focus(first))
336 .child(div().id("second").size(px(20.)).track_focus(second))
337 .child(div().id("third").size(px(20.)).track_focus(third))
338 }
339 }
340
341 fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
342 let (state, cx) = cx.add_window_view(move |window, cx| {
343 let items = [
344 cx.focus_handle().tab_stop(true),
345 cx.focus_handle().tab_stop(true),
346 cx.focus_handle().tab_stop(true),
347 ];
348 items[0].focus(window, cx);
349 NavHarness { items }
350 });
351 let items = state.read_with(cx, |harness, _| harness.items.clone());
352 cx.update(|window, cx| {
353 window.draw(cx).clear(cx);
354 });
355 (items, cx)
356 }
357
358 fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
359 cx.update(|window, _| {
360 assert!(
361 expected.is_focused(window),
362 "expected {label} to be focused"
363 );
364 });
365 }
366
367 #[gpui::test]
368 fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
369 let ([first, second, third], cx) = harness(cx);
370
371 cx.simulate_keystrokes("right");
372 assert_focused(cx, &second, "second");
373 cx.simulate_keystrokes("right");
374 assert_focused(cx, &third, "third");
375
376 cx.simulate_keystrokes("right");
378 assert_focused(cx, &first, "first (wrapped)");
379
380 cx.simulate_keystrokes("left");
381 assert_focused(cx, &third, "third (wrapped back)");
382 }
383
384 #[gpui::test]
385 fn a_vertical_toolbar_roves_with_up_and_down(cx: &mut TestAppContext) {
386 struct Vertical([FocusHandle; 3]);
387
388 impl Render for Vertical {
389 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390 let [first, second, third] = &self.0;
391 Toolbar::new("vertical-toolbar")
392 .axis(Axis::Vertical)
393 .child(div().id("first").size(px(20.)).track_focus(first))
394 .child(div().id("second").size(px(20.)).track_focus(second))
395 .child(div().id("third").size(px(20.)).track_focus(third))
396 }
397 }
398
399 let (state, cx) = cx.add_window_view(|window, cx| {
400 let items = [(); 3].map(|_| cx.focus_handle().tab_stop(true));
401 items[0].focus(window, cx);
402 Vertical(items)
403 });
404 let [first, second, third] = state.read_with(cx, |harness, _| harness.0.clone());
405 cx.update(|window, cx| window.draw(cx).clear(cx));
406
407 cx.simulate_keystrokes("right");
408 assert_focused(cx, &first, "first (Right is not a vertical key)");
409 cx.simulate_keystrokes("down");
410 assert_focused(cx, &second, "second");
411 cx.simulate_keystrokes("down down");
412 assert_focused(cx, &first, "first (wrapped)");
413 cx.simulate_keystrokes("up");
414 assert_focused(cx, &third, "third (wrapped back)");
415 }
416
417 #[gpui::test]
418 fn arrow_keys_wrap_inside_the_toolbar_among_many_page_stops(cx: &mut TestAppContext) {
419 const STOPS: usize = 120;
421
422 struct BusyPage {
423 before: Vec<FocusHandle>,
424 items: [FocusHandle; 3],
425 after: Vec<FocusHandle>,
426 }
427
428 impl Render for BusyPage {
429 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
430 let [first, second, third] = &self.items;
431 div()
432 .flex()
433 .flex_col()
434 .children(self.before.iter().enumerate().map(|(at, handle)| {
435 div().id(("before", at)).size(px(1.)).track_focus(handle)
436 }))
437 .child(
438 Toolbar::new("busy-toolbar")
439 .child(div().id("first").size(px(20.)).track_focus(first))
440 .child(div().id("second").size(px(20.)).track_focus(second))
441 .child(div().id("third").size(px(20.)).track_focus(third)),
442 )
443 .children(self.after.iter().enumerate().map(|(at, handle)| {
444 div().id(("after", at)).size(px(1.)).track_focus(handle)
445 }))
446 }
447 }
448
449 let (state, cx) = cx.add_window_view(|window, cx| {
450 let stop = |cx: &mut Context<BusyPage>| cx.focus_handle().tab_stop(true);
451 let page = BusyPage {
452 before: (0..STOPS).map(|_| stop(cx)).collect(),
453 items: [stop(cx), stop(cx), stop(cx)],
454 after: (0..STOPS).map(|_| stop(cx)).collect(),
455 };
456 page.items[0].focus(window, cx);
457 page
458 });
459 let [first, second, third] = state.read_with(cx, |page, _| page.items.clone());
460 cx.update(|window, cx| {
461 window.draw(cx).clear(cx);
462 });
463
464 cx.simulate_keystrokes("left");
465 assert_focused(cx, &third, "third (Left from the first)");
466 cx.simulate_keystrokes("right");
467 assert_focused(cx, &first, "first (Right from the last)");
468 cx.simulate_keystrokes("right");
469 assert_focused(cx, &second, "second (one step right)");
470 cx.simulate_keystrokes("left");
471 assert_focused(cx, &first, "first (one step left)");
472 }
473
474 #[gpui::test]
475 fn arrow_keys_wrap_across_more_than_a_hundred_items(cx: &mut TestAppContext) {
476 const ITEMS: usize = 120;
477
478 struct LargeToolbar {
479 outside: FocusHandle,
480 items: Vec<FocusHandle>,
481 }
482
483 impl Render for LargeToolbar {
484 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
485 div()
486 .child(div().id("outside").size(px(1.)).track_focus(&self.outside))
487 .child(Toolbar::new("large-toolbar").children(
488 self.items.iter().enumerate().map(|(at, handle)| {
489 div().id(("item", at)).size(px(1.)).track_focus(handle)
490 }),
491 ))
492 }
493 }
494
495 let (state, cx) = cx.add_window_view(|window, cx| {
496 let page = LargeToolbar {
497 outside: cx.focus_handle().tab_stop(true),
498 items: (0..ITEMS)
499 .map(|_| cx.focus_handle().tab_stop(true))
500 .collect(),
501 };
502 page.items[0].focus(window, cx);
503 page
504 });
505 let items = state.read_with(cx, |page, _| page.items.clone());
506 cx.update(|window, cx| {
507 window.draw(cx).clear(cx);
508 });
509
510 cx.simulate_keystrokes("left");
511 assert_focused(cx, &items[ITEMS - 1], "the last item (Left from the first)");
512 cx.simulate_keystrokes("right");
513 assert_focused(cx, &items[0], "the first item (Right from the last)");
514 }
515
516 #[gpui::test]
517 fn arrow_keys_pass_over_stops_ordered_between_the_items(cx: &mut TestAppContext) {
518 struct InterleavedPage {
519 items: [FocusHandle; 3],
520 outside: [FocusHandle; 2],
521 }
522
523 impl Render for InterleavedPage {
524 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
525 let [first, second, third] = &self.items;
526 let [before, after] = &self.outside;
527 div()
528 .child(
529 Toolbar::new("interleaved-toolbar")
530 .child(div().id("first").size(px(20.)).track_focus(first))
531 .child(div().id("second").size(px(20.)).track_focus(second))
532 .child(div().id("third").size(px(20.)).track_focus(third)),
533 )
534 .child(div().id("before").size(px(20.)).track_focus(before))
535 .child(div().id("after").size(px(20.)).track_focus(after))
536 }
537 }
538
539 let (state, cx) = cx.add_window_view(|window, cx| {
542 let stop = |index, cx: &mut Context<InterleavedPage>| {
543 cx.focus_handle().tab_stop(true).tab_index(index)
544 };
545 let page = InterleavedPage {
546 items: [stop(1, cx), stop(3, cx), stop(5, cx)],
547 outside: [stop(2, cx), stop(4, cx)],
548 };
549 page.items[0].focus(window, cx);
550 page
551 });
552 let [first, second, third] = state.read_with(cx, |page, _| page.items.clone());
553 cx.update(|window, cx| {
554 window.draw(cx).clear(cx);
555 });
556
557 cx.simulate_keystrokes("right");
558 assert_focused(cx, &second, "second (Right from the first)");
559 cx.simulate_keystrokes("right");
560 assert_focused(cx, &third, "third (Right from the second)");
561 cx.simulate_keystrokes("right");
562 assert_focused(cx, &first, "first (Right from the last)");
563 cx.simulate_keystrokes("left");
564 assert_focused(cx, &third, "third (Left from the first)");
565 }
566
567 #[gpui::test]
568 fn arrow_keys_keep_focus_when_no_other_item_is_a_tab_stop(cx: &mut TestAppContext) {
569 struct Unreachable {
570 item: FocusHandle,
571 outside: [FocusHandle; 2],
572 }
573
574 impl Render for Unreachable {
575 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
576 let [before, after] = &self.outside;
577 div()
578 .child(div().id("before").size(px(20.)).track_focus(before))
579 .child(
580 Toolbar::new("unreachable-toolbar")
581 .child(div().id("item").size(px(20.)).track_focus(&self.item)),
582 )
583 .child(div().id("after").size(px(20.)).track_focus(after))
584 }
585 }
586
587 let (state, cx) = cx.add_window_view(|window, cx| {
590 let page = Unreachable {
591 item: cx.focus_handle(),
592 outside: [
593 cx.focus_handle().tab_stop(true),
594 cx.focus_handle().tab_stop(true),
595 ],
596 };
597 page.item.focus(window, cx);
598 page
599 });
600 let item = state.read_with(cx, |page, _| page.item.clone());
601 cx.update(|window, cx| {
602 window.draw(cx).clear(cx);
603 });
604
605 cx.simulate_keystrokes("right left");
606 assert_focused(cx, &item, "the item (no other stop in the toolbar)");
607 }
608
609 #[gpui::test]
610 fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
611 struct SingleHarness {
612 item: FocusHandle,
613 }
614
615 impl Render for SingleHarness {
616 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
617 let item = &self.item;
618 Toolbar::new("single-toolbar")
619 .child(div().id("only").size(px(20.)).track_focus(item))
620 }
621 }
622
623 let (state, cx) = cx.add_window_view(|window, cx| {
624 let item = cx.focus_handle().tab_stop(true);
625 item.focus(window, cx);
626 SingleHarness { item }
627 });
628 let item = state.read_with(cx, |harness, _| harness.item.clone());
629 cx.update(|window, cx| {
630 window.draw(cx).clear(cx);
631 });
632
633 cx.simulate_keystrokes("right left right");
634 assert_focused(cx, &item, "the only item");
635 }
636
637 #[gpui::test]
638 fn snapshot_reports_the_toolbar_role_label_and_focus(cx: &mut gpui::TestAppContext) {
639 struct Labelled(FocusHandle);
640
641 impl Render for Labelled {
642 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
643 Toolbar::new("formatting")
644 .aria_label("Formatting")
645 .child(div().id("bold").size(px(20.)).track_focus(&self.0))
646 }
647 }
648
649 let (_, cx) = cx.add_window_view(|window, cx| {
650 let item = cx.focus_handle().tab_stop(true);
651 item.focus(window, cx);
652 Labelled(item)
653 });
654 cx.update(|window, cx| {
655 window.draw(cx).clear(cx);
656 let snapshot = crate::test_support::find(window, &[], &"formatting".into())
657 .expect("the toolbar is observed");
658 assert_eq!(snapshot.role(), Some(Role::Toolbar));
659 assert_eq!(snapshot.label(), Some("Formatting"));
660 assert_eq!(snapshot.focused(), Some(true));
663 });
664 }
665
666 #[gpui::test]
667 fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
668 type Captured = Arc<Mutex<Option<accesskit::Node>>>;
669
670 struct Probe(Captured);
671
672 impl Render for Probe {
673 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
674 let captured = self.0.clone();
675 canvas(
676 move |_, window, cx| {
677 let mut node = accesskit::Node::new(Role::Group);
678 ToolbarGroup::new("history")
679 .label("History")
680 .child(div().size(px(20.)))
681 .render(window, cx)
682 .into_element()
683 .write_a11y_info(&mut node);
684 *captured.lock().unwrap() = Some(node);
685 },
686 |_, _, _, _| {},
687 )
688 }
689 }
690
691 let captured: Captured = Arc::new(Mutex::new(None));
692 let result = captured.clone();
693 let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
694 cx.update(|window, cx| window.draw(cx).clear(cx));
695 let node = result.lock().unwrap().take().unwrap();
696
697 assert_eq!(node.role(), Role::Group);
698 assert_eq!(node.label(), Some("History"));
699 }
700
701 #[gpui::test]
702 fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
703 struct DisabledHarness {
704 items: [FocusHandle; 2],
705 }
706
707 impl Render for DisabledHarness {
708 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
709 let [first, second] = &self.items;
710 Toolbar::new("disabled-toolbar")
711 .disabled(true)
712 .child(div().id("first").size(px(20.)).track_focus(first))
713 .child(div().id("second").size(px(20.)).track_focus(second))
714 }
715 }
716
717 let (state, cx) = cx.add_window_view(|window, cx| {
718 let items = [
719 cx.focus_handle().tab_stop(true),
720 cx.focus_handle().tab_stop(true),
721 ];
722 items[0].focus(window, cx);
723 DisabledHarness { items }
724 });
725 let items = state.read_with(cx, |harness, _| harness.items.clone());
726 cx.update(|window, cx| {
727 window.draw(cx).clear(cx);
728 });
729
730 cx.simulate_keystrokes("right");
731 assert_focused(cx, &items[0], "first (disabled toolbar)");
732 }
733
734 #[gpui::test]
735 fn clicking_blank_toolbar_space_keeps_focus_outside(cx: &mut gpui::TestAppContext) {
736 struct ClickHarness {
737 outside: FocusHandle,
738 item: FocusHandle,
739 }
740
741 impl Render for ClickHarness {
742 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
743 div()
744 .child(div().id("outside").size(px(20.)).track_focus(&self.outside))
745 .child(
746 Toolbar::new("click-toolbar")
747 .flex()
748 .w(px(100.))
749 .h(px(20.))
750 .child(div().id("item").size(px(20.)).track_focus(&self.item)),
751 )
752 }
753 }
754
755 let (state, cx) = cx.add_window_view(|window, cx| {
756 let outside = cx.focus_handle().tab_stop(true);
757 outside.focus(window, cx);
758 ClickHarness {
759 outside,
760 item: cx.focus_handle().tab_stop(true),
761 }
762 });
763 let (outside, item) = state.read_with(cx, |harness, _| {
764 (harness.outside.clone(), harness.item.clone())
765 });
766 cx.update(|window, cx| {
767 window.draw(cx).clear(cx);
768 });
769
770 cx.simulate_click(point(px(80.), px(30.)), Modifiers::default());
772 assert_focused(cx, &outside, "outside (blank toolbar click)");
773
774 cx.simulate_click(point(px(10.), px(30.)), Modifiers::default());
776 assert_focused(cx, &item, "item (clicked)");
777 }
778 }
779}