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#[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 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
76fn 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 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 .on_any_mouse_down(|_, window, _| window.prevent_default())
186 .children(children)
187 .refine_style(&style)
188 }
189}
190
191#[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 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 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 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 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 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 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 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 cx.simulate_click(point(px(80.), px(30.)), Modifiers::default());
718 assert_focused(cx, &outside, "outside (blank toolbar click)");
719
720 cx.simulate_click(point(px(10.), px(30.)), Modifiers::default());
722 assert_focused(cx, &item, "item (clicked)");
723 }
724 }
725}