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 .children(children)
188 .refine_style(&style)
189 }
190}
191
192#[derive(IntoElement)]
207pub struct ToolbarGroup {
208 base: Stateful<Div>,
209 style: StyleRefinement,
210 label: Option<SharedString>,
211 children: SmallVec<[AnyElement; 4]>,
212}
213
214impl ToolbarGroup {
215 pub fn new(id: impl Into<ElementId>) -> Self {
216 Self {
217 base: div().id(id.into()),
218 style: StyleRefinement::default(),
219 label: None,
220 children: SmallVec::new(),
221 }
222 }
223
224 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
226 self.label = Some(label.into());
227 self
228 }
229}
230
231impl Styled for ToolbarGroup {
232 fn style(&mut self) -> &mut StyleRefinement {
233 &mut self.style
234 }
235}
236
237impl ParentElement for ToolbarGroup {
238 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
239 self.children.extend(elements);
240 }
241}
242
243impl InteractiveElement for ToolbarGroup {
244 fn interactivity(&mut self) -> &mut Interactivity {
245 self.base.interactivity()
246 }
247}
248
249impl StatefulInteractiveElement for ToolbarGroup {}
250
251impl RenderOnce for ToolbarGroup {
252 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
253 self.base
258 .flex()
259 .items_center()
260 .role(Role::Group)
261 .when_some(self.label, |this, label| this.aria_label(label))
262 .children(self.children)
263 .refine_style(&self.style)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_toolbar_builder() {
273 let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
274
275 assert!(toolbar.disabled);
276 assert_eq!(toolbar.children.len(), 1);
277 }
278
279 #[test]
280 fn test_toolbar_defaults() {
281 let toolbar = Toolbar::new("toolbar");
282
283 assert!(!toolbar.disabled);
284 assert!(toolbar.children.is_empty());
285 }
286
287 #[test]
288 fn test_toolbar_group_builder() {
289 let group = ToolbarGroup::new("history-group")
290 .label("History")
291 .child(div())
292 .child(div());
293
294 assert_eq!(group.label.as_deref(), Some("History"));
295 assert_eq!(group.children.len(), 2);
296 }
297
298 #[cfg(test)]
299 mod behavior {
300 use super::*;
301 use gpui::{
302 Context, Element as _, FocusHandle, Render, TestAppContext, VisualTestContext, canvas,
303 px,
304 };
305 use std::sync::{Arc, Mutex};
306
307 struct NavHarness {
308 items: [FocusHandle; 3],
309 }
310
311 impl Render for NavHarness {
312 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
313 let [first, second, third] = &self.items;
314 Toolbar::new("nav-toolbar")
315 .child(div().id("first").size(px(20.)).track_focus(first))
316 .child(div().id("second").size(px(20.)).track_focus(second))
317 .child(div().id("third").size(px(20.)).track_focus(third))
318 }
319 }
320
321 fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
322 let (state, cx) = cx.add_window_view(move |window, cx| {
323 let items = [
324 cx.focus_handle().tab_stop(true),
325 cx.focus_handle().tab_stop(true),
326 cx.focus_handle().tab_stop(true),
327 ];
328 items[0].focus(window, cx);
329 NavHarness { items }
330 });
331 let items = state.read_with(cx, |harness, _| harness.items.clone());
332 cx.update(|window, cx| {
333 window.draw(cx).clear(cx);
334 });
335 (items, cx)
336 }
337
338 fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
339 cx.update(|window, _| {
340 assert!(
341 expected.is_focused(window),
342 "expected {label} to be focused"
343 );
344 });
345 }
346
347 #[gpui::test]
348 fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
349 let ([first, second, third], cx) = harness(cx);
350
351 cx.simulate_keystrokes("right");
352 assert_focused(cx, &second, "second");
353 cx.simulate_keystrokes("right");
354 assert_focused(cx, &third, "third");
355
356 cx.simulate_keystrokes("right");
358 assert_focused(cx, &first, "first (wrapped)");
359
360 cx.simulate_keystrokes("left");
361 assert_focused(cx, &third, "third (wrapped back)");
362 }
363
364 #[gpui::test]
365 fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
366 struct SingleHarness {
367 item: FocusHandle,
368 }
369
370 impl Render for SingleHarness {
371 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
372 let item = &self.item;
373 Toolbar::new("single-toolbar")
374 .child(div().id("only").size(px(20.)).track_focus(item))
375 }
376 }
377
378 let (state, cx) = cx.add_window_view(|window, cx| {
379 let item = cx.focus_handle().tab_stop(true);
380 item.focus(window, cx);
381 SingleHarness { item }
382 });
383 let item = state.read_with(cx, |harness, _| harness.item.clone());
384 cx.update(|window, cx| {
385 window.draw(cx).clear(cx);
386 });
387
388 cx.simulate_keystrokes("right left right");
389 assert_focused(cx, &item, "the only item");
390 }
391
392 #[gpui::test]
393 fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
394 type Captured = Arc<Mutex<Option<accesskit::Node>>>;
395
396 struct Probe(Captured);
397
398 impl Render for Probe {
399 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
400 let captured = self.0.clone();
401 canvas(
402 move |_, window, cx| {
403 let mut node = accesskit::Node::new(Role::Group);
404 ToolbarGroup::new("history")
405 .label("History")
406 .child(div().size(px(20.)))
407 .render(window, cx)
408 .into_element()
409 .write_a11y_info(&mut node);
410 *captured.lock().unwrap() = Some(node);
411 },
412 |_, _, _, _| {},
413 )
414 }
415 }
416
417 let captured: Captured = Arc::new(Mutex::new(None));
418 let result = captured.clone();
419 let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
420 cx.update(|window, cx| window.draw(cx).clear(cx));
421 let node = result.lock().unwrap().take().unwrap();
422
423 assert_eq!(node.role(), Role::Group);
424 assert_eq!(node.label(), Some("History"));
425 }
426
427 #[gpui::test]
428 fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
429 struct DisabledHarness {
430 items: [FocusHandle; 2],
431 }
432
433 impl Render for DisabledHarness {
434 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
435 let [first, second] = &self.items;
436 Toolbar::new("disabled-toolbar")
437 .disabled(true)
438 .child(div().id("first").size(px(20.)).track_focus(first))
439 .child(div().id("second").size(px(20.)).track_focus(second))
440 }
441 }
442
443 let (state, cx) = cx.add_window_view(|window, cx| {
444 let items = [
445 cx.focus_handle().tab_stop(true),
446 cx.focus_handle().tab_stop(true),
447 ];
448 items[0].focus(window, cx);
449 DisabledHarness { items }
450 });
451 let items = state.read_with(cx, |harness, _| harness.items.clone());
452 cx.update(|window, cx| {
453 window.draw(cx).clear(cx);
454 });
455
456 cx.simulate_keystrokes("right");
457 assert_focused(cx, &items[0], "first (disabled toolbar)");
458 }
459 }
460}