use gpui::{
AnyElement, App, Div, ElementId, FocusHandle, InteractiveElement, Interactivity, IntoElement,
KeyDownEvent, ParentElement, RenderOnce, Role, SharedString, Stateful,
StatefulInteractiveElement, StyleRefinement, Styled, Window, accesskit, div,
prelude::FluentBuilder as _,
};
use smallvec::SmallVec;
use crate::StyledExt as _;
const MAX_FOCUS_ATTEMPTS: usize = 100;
#[derive(IntoElement)]
pub struct Toolbar {
id: ElementId,
base: Stateful<Div>,
style: StyleRefinement,
disabled: bool,
children: SmallVec<[AnyElement; 4]>,
}
impl Toolbar {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
Self {
base: div().id(id.clone()),
style: StyleRefinement::default(),
disabled: false,
children: SmallVec::new(),
id,
}
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
fn step_focus(window: &mut Window, cx: &mut App, forward: bool) {
if forward {
window.focus_next(cx);
} else {
window.focus_prev(cx);
}
}
fn move_focus(container: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
let Some(start) = window.focused(cx) else {
return;
};
step_focus(window, cx, forward);
if container.contains_focused(window, cx)
&& window.focused(cx).is_some_and(|focused| focused != start)
{
return;
}
for _ in 0..MAX_FOCUS_ATTEMPTS {
step_focus(window, cx, forward);
if container.contains_focused(window, cx)
&& window.focused(cx).is_some_and(|focused| focused != start)
{
return;
}
if window.focused(cx).is_some_and(|focused| focused == start) {
break;
}
}
window.focus(&start, cx);
}
fn handle_key_down(
disabled: bool,
container: &FocusHandle,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut App,
) {
if disabled {
return;
}
let forward = match event.keystroke.key.as_str() {
"left" => Some(false),
"right" => Some(true),
_ => None,
};
if let Some(forward) = forward {
move_focus(container, forward, window, cx);
cx.stop_propagation();
}
}
impl Styled for Toolbar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for Toolbar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl InteractiveElement for Toolbar {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Toolbar {}
impl RenderOnce for Toolbar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
base,
style,
disabled,
children,
..
} = self;
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_, cx| {
cx.focus_handle().tab_stop(false)
})
.read(cx)
.clone();
let key_handler = {
let focus_handle = focus_handle.clone();
move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| {
handle_key_down(disabled, &focus_handle, event, window, cx);
}
};
base.track_focus(&focus_handle)
.role(Role::Toolbar)
.aria_orientation(accesskit::Orientation::Horizontal)
.on_key_down(key_handler)
.children(children)
.refine_style(&style)
}
}
#[derive(IntoElement)]
pub struct ToolbarGroup {
base: Stateful<Div>,
style: StyleRefinement,
label: Option<SharedString>,
children: SmallVec<[AnyElement; 4]>,
}
impl ToolbarGroup {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().id(id.into()),
style: StyleRefinement::default(),
label: None,
children: SmallVec::new(),
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
}
impl Styled for ToolbarGroup {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for ToolbarGroup {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl InteractiveElement for ToolbarGroup {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for ToolbarGroup {}
impl RenderOnce for ToolbarGroup {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.base
.flex()
.items_center()
.role(Role::Group)
.when_some(self.label, |this, label| this.aria_label(label))
.children(self.children)
.refine_style(&self.style)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_toolbar_builder() {
let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
assert!(toolbar.disabled);
assert_eq!(toolbar.children.len(), 1);
}
#[test]
fn test_toolbar_defaults() {
let toolbar = Toolbar::new("toolbar");
assert!(!toolbar.disabled);
assert!(toolbar.children.is_empty());
}
#[test]
fn test_toolbar_group_builder() {
let group = ToolbarGroup::new("history-group")
.label("History")
.child(div())
.child(div());
assert_eq!(group.label.as_deref(), Some("History"));
assert_eq!(group.children.len(), 2);
}
#[cfg(test)]
mod behavior {
use super::*;
use gpui::{
Context, Element as _, FocusHandle, Render, TestAppContext, VisualTestContext, canvas,
px,
};
use std::sync::{Arc, Mutex};
struct NavHarness {
items: [FocusHandle; 3],
}
impl Render for NavHarness {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let [first, second, third] = &self.items;
Toolbar::new("nav-toolbar")
.child(div().id("first").size(px(20.)).track_focus(first))
.child(div().id("second").size(px(20.)).track_focus(second))
.child(div().id("third").size(px(20.)).track_focus(third))
}
}
fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
let (state, cx) = cx.add_window_view(move |window, cx| {
let items = [
cx.focus_handle().tab_stop(true),
cx.focus_handle().tab_stop(true),
cx.focus_handle().tab_stop(true),
];
items[0].focus(window, cx);
NavHarness { items }
});
let items = state.read_with(cx, |harness, _| harness.items.clone());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
(items, cx)
}
fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
cx.update(|window, _| {
assert!(
expected.is_focused(window),
"expected {label} to be focused"
);
});
}
#[gpui::test]
fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
let ([first, second, third], cx) = harness(cx);
cx.simulate_keystrokes("right");
assert_focused(cx, &second, "second");
cx.simulate_keystrokes("right");
assert_focused(cx, &third, "third");
cx.simulate_keystrokes("right");
assert_focused(cx, &first, "first (wrapped)");
cx.simulate_keystrokes("left");
assert_focused(cx, &third, "third (wrapped back)");
}
#[gpui::test]
fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
struct SingleHarness {
item: FocusHandle,
}
impl Render for SingleHarness {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let item = &self.item;
Toolbar::new("single-toolbar")
.child(div().id("only").size(px(20.)).track_focus(item))
}
}
let (state, cx) = cx.add_window_view(|window, cx| {
let item = cx.focus_handle().tab_stop(true);
item.focus(window, cx);
SingleHarness { item }
});
let item = state.read_with(cx, |harness, _| harness.item.clone());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
cx.simulate_keystrokes("right left right");
assert_focused(cx, &item, "the only item");
}
#[gpui::test]
fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
type Captured = Arc<Mutex<Option<accesskit::Node>>>;
struct Probe(Captured);
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let captured = self.0.clone();
canvas(
move |_, window, cx| {
let mut node = accesskit::Node::new(Role::Group);
ToolbarGroup::new("history")
.label("History")
.child(div().size(px(20.)))
.render(window, cx)
.into_element()
.write_a11y_info(&mut node);
*captured.lock().unwrap() = Some(node);
},
|_, _, _, _| {},
)
}
}
let captured: Captured = Arc::new(Mutex::new(None));
let result = captured.clone();
let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
cx.update(|window, cx| window.draw(cx).clear(cx));
let node = result.lock().unwrap().take().unwrap();
assert_eq!(node.role(), Role::Group);
assert_eq!(node.label(), Some("History"));
}
#[gpui::test]
fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
struct DisabledHarness {
items: [FocusHandle; 2],
}
impl Render for DisabledHarness {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let [first, second] = &self.items;
Toolbar::new("disabled-toolbar")
.disabled(true)
.child(div().id("first").size(px(20.)).track_focus(first))
.child(div().id("second").size(px(20.)).track_focus(second))
}
}
let (state, cx) = cx.add_window_view(|window, cx| {
let items = [
cx.focus_handle().tab_stop(true),
cx.focus_handle().tab_stop(true),
];
items[0].focus(window, cx);
DisabledHarness { items }
});
let items = state.read_with(cx, |harness, _| harness.items.clone());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
cx.simulate_keystrokes("right");
assert_focused(cx, &items[0], "first (disabled toolbar)");
}
}
}