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
11const MAX_FOCUS_ATTEMPTS: usize = 100;
15
16#[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 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
81fn 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 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 .on_any_mouse_down(|_, window, _| window.prevent_default())
192 .children(children)
193 .refine_style(&style)
194 }
195}
196
197#[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 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 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 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 cx.simulate_click(point(px(80.), px(30.)), Modifiers::default());
503 assert_focused(cx, &outside, "outside (blank toolbar click)");
504
505 cx.simulate_click(point(px(10.), px(30.)), Modifiers::default());
507 assert_focused(cx, &item, "item (clicked)");
508 }
509 }
510}