Skip to main content

gpui_component/
toolbar.rs

1use gpui::{
2    AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString,
3    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
4};
5use smallvec::SmallVec;
6
7use gpui_base::{Toolbar as BaseToolbar, ToolbarGroup as BaseToolbarGroup};
8
9use crate::{Sizable, Size, StyleSized as _, StyledExt as _};
10
11enum ToolbarItem {
12    Sized(Box<dyn FnOnce(Size) -> AnyElement>),
13    Content(AnyElement),
14}
15
16impl ToolbarItem {
17    fn sized(item: impl Sizable + IntoElement + 'static) -> Self {
18        Self::Sized(Box::new(move |size| {
19            item.prepare_for_toolbar()
20                .with_size(size)
21                .into_any_element()
22        }))
23    }
24
25    fn content(content: impl IntoElement) -> Self {
26        Self::Content(content.into_any_element())
27    }
28
29    fn into_element(self, size: Size) -> AnyElement {
30        match self {
31            Self::Sized(item) => div()
32                .flex()
33                .items_center()
34                .input_h(size)
35                .child(item(size))
36                .into_any_element(),
37            Self::Content(content) => content,
38        }
39    }
40}
41
42/// A semantic subgroup of toolbar controls that shares one accessible label
43/// and propagates its size to every control added with `child` or `children`.
44#[derive(IntoElement)]
45pub struct ToolbarGroup {
46    id: ElementId,
47    style: StyleRefinement,
48    size: Size,
49    label: Option<SharedString>,
50    children: SmallVec<[ToolbarItem; 4]>,
51}
52
53impl ToolbarGroup {
54    pub fn new(id: impl Into<ElementId>) -> Self {
55        Self {
56            id: id.into(),
57            style: StyleRefinement::default(),
58            size: Size::Small,
59            label: None,
60            children: SmallVec::new(),
61        }
62    }
63
64    /// Sets the accessible name announced for the group.
65    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
66        self.label = Some(label.into());
67        self
68    }
69
70    /// Append a control that inherits the group's final size.
71    pub fn child(mut self, child: impl Sizable + IntoElement + 'static) -> Self {
72        self.children.push(ToolbarItem::sized(child));
73        self
74    }
75
76    /// Append controls that inherit the group's final size.
77    pub fn children<T>(mut self, children: impl IntoIterator<Item = T>) -> Self
78    where
79        T: Sizable + IntoElement + 'static,
80    {
81        self.children
82            .extend(children.into_iter().map(ToolbarItem::sized));
83        self
84    }
85
86    /// Append non-sized content to the group.
87    pub fn content(mut self, content: impl IntoElement) -> Self {
88        self.children.push(ToolbarItem::content(content));
89        self
90    }
91}
92
93impl ParentElement for ToolbarGroup {
94    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
95        self.children
96            .extend(elements.into_iter().map(ToolbarItem::Content));
97    }
98}
99
100impl Styled for ToolbarGroup {
101    fn style(&mut self) -> &mut StyleRefinement {
102        &mut self.style
103    }
104}
105
106impl Sizable for ToolbarGroup {
107    fn with_size(mut self, size: impl Into<Size>) -> Self {
108        self.size = match size.into() {
109            Size::Large => Size::Medium,
110            size => size,
111        };
112        self
113    }
114}
115
116impl RenderOnce for ToolbarGroup {
117    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
118        let size = self.size;
119        BaseToolbarGroup::new(self.id)
120            .when_some(self.label, |this, label| this.label(label))
121            .children(
122                self.children
123                    .into_iter()
124                    .map(|item| item.into_element(size)),
125            )
126            .refine_style(&self.style)
127    }
128}
129
130/// A transparent horizontal container for commands in a window, pane, or
131/// section.
132///
133/// The toolbar owns layout and keyboard behavior while the surrounding header,
134/// tab strip, or custom surface owns its background and border.
135///
136/// The bar exposes `Toolbar` semantics to assistive technology and owns
137/// roving keyboard focus: when focus is on one of its controls, the arrow
138/// keys move focus along the bar, wrapping around at the ends. Hosted inputs
139/// keep their own arrow-key caret behavior; place them at the trailing end of
140/// the bar.
141///
142/// `child` accepts [`Sizable`] controls and automatically applies the toolbar's
143/// final size, regardless of builder order. Use `content` for separators,
144/// labels, flexible spacers, and custom layout. Items render in source order,
145/// matching Base UI's toolbar composition model. An icon-only button must
146/// carry a tooltip and an accessible name.
147///
148/// The id keeps the toolbar's keyboard-focus state stable across frames;
149/// give each toolbar in a window a distinct id.
150///
151/// ```
152/// # mod gpui_kit { pub extern crate gpui_component as component; }
153/// use gpui_kit::component::toolbar::Toolbar;
154///
155/// let _ = Toolbar::new("document-toolbar").content("Document");
156/// ```
157#[derive(IntoElement)]
158pub struct Toolbar {
159    id: ElementId,
160    style: StyleRefinement,
161    size: Size,
162    disabled: bool,
163    items: SmallVec<[ToolbarItem; 4]>,
164}
165
166impl Toolbar {
167    /// Create a new, empty [`Toolbar`] at [`Size::Small`].
168    pub fn new(id: impl Into<ElementId>) -> Self {
169        Self {
170            id: id.into(),
171            style: StyleRefinement::default(),
172            size: Size::Small,
173            disabled: false,
174            items: SmallVec::new(),
175        }
176    }
177
178    /// Disable the toolbar's roving keyboard navigation.
179    /// Hosted controls must be disabled by their owner.
180    pub fn disabled(mut self, disabled: bool) -> Self {
181        self.disabled = disabled;
182        self
183    }
184
185    /// Append a sized control. The toolbar applies its
186    /// final size when it renders, so builder call order does not matter.
187    pub fn child(mut self, child: impl Sizable + IntoElement + 'static) -> Self {
188        self.items.push(ToolbarItem::sized(child));
189        self
190    }
191
192    /// Append sized controls.
193    pub fn children<T>(mut self, children: impl IntoIterator<Item = T>) -> Self
194    where
195        T: Sizable + IntoElement + 'static,
196    {
197        self.items
198            .extend(children.into_iter().map(ToolbarItem::sized));
199        self
200    }
201
202    /// Append non-sized content.
203    pub fn content(mut self, content: impl IntoElement) -> Self {
204        self.items.push(ToolbarItem::content(content));
205        self
206    }
207
208    /// Append non-sized content.
209    pub fn contents(mut self, contents: impl IntoIterator<Item = AnyElement>) -> Self {
210        self.items
211            .extend(contents.into_iter().map(ToolbarItem::Content));
212        self
213    }
214}
215
216/// Generic [`ParentElement`] extension treats elements as non-sized content.
217/// Prefer the inherent `child` / `children` methods for controls that should
218/// inherit the toolbar size.
219impl ParentElement for Toolbar {
220    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
221        self.items
222            .extend(elements.into_iter().map(ToolbarItem::Content));
223    }
224}
225
226impl Styled for Toolbar {
227    fn style(&mut self) -> &mut StyleRefinement {
228        &mut self.style
229    }
230}
231
232impl Sizable for Toolbar {
233    fn with_size(mut self, size: impl Into<Size>) -> Self {
234        self.size = match size.into() {
235            Size::Large => Size::Medium,
236            size => size,
237        };
238        self
239    }
240}
241
242impl RenderOnce for Toolbar {
243    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
244        let size = self.size;
245        let items = self.items.into_iter().map(|item| item.into_element(size));
246
247        BaseToolbar::new(self.id)
248            .disabled(self.disabled)
249            .flex()
250            .items_center()
251            .flex_shrink_0()
252            .map(|this| match size {
253                Size::XSmall => this.h_7().p_1().gap_1().text_xs(),
254                Size::Small => this.h_8().p_1().gap_1().text_sm(),
255                _ => this.h_12().p_2().gap_2().text_sm(),
256            })
257            .refine_style(&self.style)
258            .children(items)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::button::{Button, ButtonVariant};
266    use gpui::{Context, Render, TestAppContext, div};
267    use std::sync::{Arc, Mutex};
268
269    #[derive(IntoElement)]
270    struct SizeProbe {
271        size: Size,
272        observed: Arc<Mutex<Option<Size>>>,
273    }
274
275    impl SizeProbe {
276        fn new(observed: Arc<Mutex<Option<Size>>>) -> Self {
277            Self {
278                size: Size::default(),
279                observed,
280            }
281        }
282    }
283
284    impl Sizable for SizeProbe {
285        fn with_size(mut self, size: impl Into<Size>) -> Self {
286            self.size = size.into();
287            self
288        }
289    }
290
291    impl RenderOnce for SizeProbe {
292        fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
293            *self.observed.lock().unwrap() = Some(self.size);
294            div()
295        }
296    }
297
298    struct ToolbarHarness {
299        items: [Arc<Mutex<Option<Size>>>; 3],
300    }
301
302    struct ToolbarGroupHarness {
303        item: Arc<Mutex<Option<Size>>>,
304    }
305
306    impl Render for ToolbarGroupHarness {
307        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
308            ToolbarGroup::new("group")
309                .child(SizeProbe::new(self.item.clone()))
310                .small()
311        }
312    }
313
314    impl Render for ToolbarHarness {
315        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
316            Toolbar::new("toolbar")
317                .children(self.items.iter().cloned().map(SizeProbe::new))
318                .small()
319        }
320    }
321
322    #[test]
323    fn test_toolbar_builder() {
324        let toolbar = Toolbar::new("toolbar")
325            .content("New")
326            .content("Open")
327            .content("Settings")
328            .small();
329
330        assert_eq!(toolbar.items.len(), 3);
331        assert_eq!(toolbar.size, Size::Small);
332    }
333
334    #[test]
335    fn test_toolbar_default() {
336        let toolbar = Toolbar::new("toolbar");
337
338        assert_eq!(toolbar.size, Size::Small);
339        assert!(!toolbar.disabled);
340        assert!(toolbar.items.is_empty());
341    }
342
343    #[test]
344    fn large_size_falls_back_to_medium() {
345        assert_eq!(Toolbar::new("toolbar").large().size, Size::Medium);
346        assert_eq!(ToolbarGroup::new("group").large().size, Size::Medium);
347    }
348
349    #[test]
350    fn toolbar_prepares_buttons_as_compact_ghost_commands() {
351        let button = Button::new("command").prepare_for_toolbar();
352
353        assert_eq!(button.variant(), ButtonVariant::Ghost);
354        assert!(button.is_compact());
355    }
356
357    #[gpui::test]
358    fn toolbar_size_propagates_to_items_independent_of_builder_order(cx: &mut TestAppContext) {
359        cx.update(crate::init);
360        let expected = std::array::from_fn(|_| Arc::new(Mutex::new(None)));
361        let items = expected.clone();
362        let (_, cx) = cx.add_window_view(move |_, _| ToolbarHarness { items });
363        cx.update(|window, cx| window.draw(cx).clear(cx));
364
365        for observed in expected {
366            assert_eq!(*observed.lock().unwrap(), Some(Size::Small));
367        }
368    }
369
370    #[gpui::test]
371    fn toolbar_group_propagates_its_size_to_items(cx: &mut TestAppContext) {
372        let item = Arc::new(Mutex::new(None));
373        let observed = item.clone();
374        let (_, cx) = cx.add_window_view(move |_, _| ToolbarGroupHarness { item });
375        cx.update(|window, cx| window.draw(cx).clear(cx));
376
377        assert_eq!(*observed.lock().unwrap(), Some(Size::Small));
378    }
379}