Skip to main content

gpui_component/sidebar/
menu.rs

1use crate::{
2    ActiveTheme as _, Collapsible, Icon, IconName, Placement, Sizable as _, StyledExt,
3    button::{Button, ButtonVariants as _},
4    h_flex,
5    menu::{ContextMenuExt, PopupMenu},
6    sidebar::SidebarItem,
7    tooltip::{ManagedTooltipExt as _, Tooltip},
8    v_flex,
9};
10use gpui::{
11    AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
12    ParentElement as _, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement,
13    Styled, Window, div, percentage, prelude::FluentBuilder,
14};
15use gpui_base::TestSupportExt as _;
16use std::rc::Rc;
17
18/// Menu for the [`super::Sidebar`]
19#[derive(Clone)]
20pub struct SidebarMenu {
21    style: StyleRefinement,
22    collapsed: bool,
23    items: Vec<SidebarMenuItem>,
24}
25
26impl SidebarMenu {
27    /// Create a new SidebarMenu
28    pub fn new() -> Self {
29        Self {
30            style: StyleRefinement::default(),
31            items: Vec::new(),
32            collapsed: false,
33        }
34    }
35
36    /// Add a [`SidebarMenuItem`] child menu item to the sidebar menu.
37    ///
38    /// See also [`SidebarMenu::children`].
39    pub fn child(mut self, child: impl Into<SidebarMenuItem>) -> Self {
40        self.items.push(child.into());
41        self
42    }
43
44    /// Add multiple [`SidebarMenuItem`] child menu items to the sidebar menu.
45    pub fn children(
46        mut self,
47        children: impl IntoIterator<Item = impl Into<SidebarMenuItem>>,
48    ) -> Self {
49        self.items = children.into_iter().map(Into::into).collect();
50        self
51    }
52}
53
54impl Collapsible for SidebarMenu {
55    fn is_collapsed(&self) -> bool {
56        self.collapsed
57    }
58
59    fn collapsed(mut self, collapsed: bool) -> Self {
60        self.collapsed = collapsed;
61        self
62    }
63}
64
65impl SidebarItem for SidebarMenu {
66    fn render(
67        self,
68        id: impl Into<ElementId>,
69        window: &mut Window,
70        cx: &mut App,
71    ) -> impl IntoElement {
72        let id = id.into();
73
74        v_flex()
75            .gap_2()
76            .refine_style(&self.style)
77            .children(self.items.into_iter().enumerate().map(|(ix, item)| {
78                let id = SharedString::from(format!("{}-{}", id, ix));
79                item.collapsed(self.collapsed)
80                    .render(id, window, cx)
81                    .into_any_element()
82            }))
83    }
84}
85
86impl Styled for SidebarMenu {
87    fn style(&mut self) -> &mut StyleRefinement {
88        &mut self.style
89    }
90}
91
92/// Menu item for the [`SidebarMenu`]
93#[derive(Clone)]
94pub struct SidebarMenuItem {
95    icon: Option<Icon>,
96    label: SharedString,
97    accessibility_label: Option<SharedString>,
98    label_style: StyleRefinement,
99    style: StyleRefinement,
100    handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
101    active: bool,
102    default_open: bool,
103    click_to_open: bool,
104    collapsed: bool,
105    click_to_toggle: bool,
106    children: Vec<Self>,
107    suffix: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
108    disabled: bool,
109    context_menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static>>,
110}
111
112impl SidebarMenuItem {
113    /// Create a new [`SidebarMenuItem`] with a label.
114    pub fn new(label: impl Into<SharedString>) -> Self {
115        Self {
116            icon: None,
117            label: label.into(),
118            accessibility_label: None,
119            label_style: StyleRefinement::default(),
120            style: StyleRefinement::default(),
121            handler: Rc::new(|_, _, _| {}),
122            active: false,
123            collapsed: false,
124            default_open: false,
125            click_to_open: false,
126            click_to_toggle: false,
127            children: Vec::new(),
128            suffix: None,
129            disabled: false,
130            context_menu: None,
131        }
132    }
133
134    /// Set the icon for the menu item
135    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
136        self.icon = Some(icon.into());
137        self
138    }
139
140    /// Set the style for the label
141    pub fn label_style(mut self, style: StyleRefinement) -> Self {
142        self.label_style = style;
143        self
144    }
145
146    /// Set the accessibility label of the item row.
147    ///
148    /// Defaults to the visible label text.
149    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
150        self.accessibility_label = Some(label.into());
151        self
152    }
153
154    /// Set the active state of the menu item
155    pub fn active(mut self, active: bool) -> Self {
156        self.active = active;
157        self
158    }
159
160    /// Add a click handler to the menu item
161    pub fn on_click(
162        mut self,
163        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
164    ) -> Self {
165        self.handler = Rc::new(handler);
166        self
167    }
168
169    /// Set the collapsed state of the menu item
170    pub fn collapsed(mut self, collapsed: bool) -> Self {
171        self.collapsed = collapsed;
172        self
173    }
174
175    /// Set the default open state of the Submenu, default is `false`.
176    ///
177    /// This only used on initial render, the internal state will be used afterwards.
178    pub fn default_open(mut self, open: bool) -> Self {
179        self.default_open = open;
180        self
181    }
182
183    /// Set whether clicking the menu item open the submenu.
184    ///
185    /// Default is `false`.
186    ///
187    /// If `false` we only handle open/close via the caret button.
188    pub fn click_to_open(mut self, click_to_open: bool) -> Self {
189        self.click_to_open = click_to_open;
190        self
191    }
192
193    /// Set whether clicking the menu item toggles the submenu.
194    ///
195    /// If click_to_open is `true`, this has no effect.
196    ///
197    /// Default is `false`.
198    pub fn click_to_toggle(mut self, click_to_toggle: bool) -> Self {
199        self.click_to_toggle = click_to_toggle;
200        self
201    }
202
203    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
204        self.children = children.into_iter().map(Into::into).collect();
205        self
206    }
207
208    /// Set the suffix for the menu item.
209    pub fn suffix<F, E>(mut self, builder: F) -> Self
210    where
211        F: Fn(&mut Window, &mut App) -> E + 'static,
212        E: IntoElement,
213    {
214        self.suffix = Some(Rc::new(move |window, cx| {
215            builder(window, cx).into_any_element()
216        }));
217        self
218    }
219
220    /// Set disabled flat for menu item.
221    pub fn disable(mut self, disable: bool) -> Self {
222        self.disabled = disable;
223        self
224    }
225
226    fn is_submenu(&self) -> bool {
227        self.children.len() > 0
228    }
229
230    fn collapsed_tooltip(&self) -> Option<SharedString> {
231        (self.collapsed && self.icon.is_some()).then(|| self.label.clone())
232    }
233
234    /// Accessible name for the submenu caret: the toggle is icon-only,
235    /// so assistive tech needs the item it acts on.
236    fn caret_accessibility_label(&self) -> SharedString {
237        format!("Toggle {} submenu", self.label).into()
238    }
239
240    /// Set the context menu for the menu item.
241    pub fn context_menu(
242        mut self,
243        f: impl Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static,
244    ) -> Self {
245        self.context_menu = Some(Rc::new(f));
246        self
247    }
248}
249
250impl FluentBuilder for SidebarMenuItem {}
251
252impl Collapsible for SidebarMenuItem {
253    fn is_collapsed(&self) -> bool {
254        self.collapsed
255    }
256
257    fn collapsed(mut self, collapsed: bool) -> Self {
258        self.collapsed = collapsed;
259        self
260    }
261}
262
263impl SidebarItem for SidebarMenuItem {
264    fn render(
265        self,
266        id: impl Into<ElementId>,
267        window: &mut Window,
268        cx: &mut App,
269    ) -> impl IntoElement {
270        let click_to_open = self.click_to_open;
271        let click_to_toggle = self.click_to_toggle;
272        let default_open = self.default_open;
273        let collapsed_tooltip = self.collapsed_tooltip();
274        let id = id.into();
275        let label_text = self.label.clone();
276        let accessibility_label = self.accessibility_label.clone();
277        let is_submenu = self.is_submenu();
278        let open_state = if is_submenu {
279            Some(window.use_keyed_state(id.clone(), cx, |_, _| default_open))
280        } else {
281            None
282        };
283        let handler = self.handler.clone();
284        let is_collapsed = self.collapsed;
285        let is_active = self.active;
286        let is_hoverable = !is_active && !self.disabled;
287        let is_disabled = self.disabled;
288        let is_open = open_state
289            .as_ref()
290            .map_or(false, |s| !is_collapsed && *s.read(cx));
291
292        div()
293            .id(id.clone())
294            .test_support()
295            .w_full()
296            .child(
297                h_flex()
298                    .size_full()
299                    .id("item")
300                    .role(Role::TreeItem)
301                    .aria_label(accessibility_label.unwrap_or_else(|| label_text.clone()))
302                    .aria_selected(is_active)
303                    .overflow_x_hidden()
304                    .flex_shrink_0()
305                    .p_2()
306                    .gap_x_2()
307                    .rounded(cx.theme().radius)
308                    .text_sm()
309                    .refine_style(&self.style)
310                    .when(is_hoverable, |this| {
311                        this.hover(|this| {
312                            this.bg(cx.theme().sidebar_accent.opacity(0.8))
313                                .text_color(cx.theme().sidebar_accent_foreground)
314                        })
315                    })
316                    .when(is_active, |this| {
317                        this.font_medium()
318                            .bg(cx.theme().tokens.sidebar_accent)
319                            .text_color(cx.theme().sidebar_accent_foreground)
320                    })
321                    .when_some(self.icon.clone(), |this, icon| this.child(icon))
322                    .when(is_collapsed, |this| {
323                        this.justify_center().when(is_active, |this| {
324                            this.bg(cx.theme().tokens.sidebar_accent)
325                                .text_color(cx.theme().sidebar_accent_foreground)
326                        })
327                    })
328                    .when(!is_collapsed, |this| {
329                        this.h_7()
330                            .child(
331                                h_flex()
332                                    .flex_1()
333                                    .gap_x_2()
334                                    .justify_between()
335                                    .overflow_x_hidden()
336                                    .child(
337                                        h_flex()
338                                            .flex_1()
339                                            .overflow_x_hidden()
340                                            .refine_style(&self.label_style)
341                                            .child(self.label.clone()),
342                                    )
343                                    .when_some(self.suffix.clone(), |this, suffix| {
344                                        this.child(suffix(window, cx).into_any_element())
345                                    }),
346                            )
347                            .when_some(open_state.clone(), |this, open_state| {
348                                this.child(
349                                    Button::new("caret")
350                                        .xsmall()
351                                        .ghost()
352                                        .accessibility_label(self.caret_accessibility_label())
353                                        .icon(
354                                            Icon::new(IconName::ChevronRight)
355                                                .size_4()
356                                                .when(is_open, |this| {
357                                                    this.rotate(percentage(90. / 360.))
358                                                }),
359                                        )
360                                        .on_click({
361                                            move |_, _, cx| {
362                                                // Avoid trigger item click, just expand/collapse submenu
363                                                cx.stop_propagation();
364                                                open_state.update(cx, |is_open, cx| {
365                                                    *is_open = !*is_open;
366                                                    cx.notify();
367                                                })
368                                            }
369                                        }),
370                                )
371                            })
372                    })
373                    .when(is_disabled, |this| {
374                        this.text_color(cx.theme().muted_foreground)
375                    })
376                    .when(!is_disabled, |this| {
377                        this.on_click({
378                            let open_state = open_state.clone();
379                            move |ev, window, cx| {
380                                if click_to_open {
381                                    if let Some(ref s) = open_state {
382                                        s.update(cx, |is_open: &mut bool, cx| {
383                                            *is_open = true;
384                                            cx.notify();
385                                        });
386                                    }
387                                } else if click_to_toggle {
388                                    if let Some(ref s) = open_state {
389                                        s.update(cx, |is_open: &mut bool, cx| {
390                                            *is_open = !*is_open;
391                                            cx.notify();
392                                        });
393                                    }
394                                }
395                                handler(ev, window, cx)
396                            }
397                        })
398                    })
399                    .map(|this| {
400                        if let Some(tooltip) = collapsed_tooltip {
401                            this.managed_tooltip_at(Placement::Right, move |window, cx| {
402                                Tooltip::new(tooltip.clone()).build(window, cx)
403                            })
404                        } else {
405                            this
406                        }
407                    })
408                    .map(|this| {
409                        if let Some(context_menu) = self.context_menu {
410                            this.context_menu(move |menu, window, cx| {
411                                context_menu(menu, window, cx)
412                            })
413                            .into_any_element()
414                        } else {
415                            this.into_any_element()
416                        }
417                    }),
418            )
419            .when(is_open, |this| {
420                this.child(
421                    v_flex()
422                        .id("submenu")
423                        .border_l_1()
424                        .border_color(cx.theme().sidebar_border)
425                        .gap_1()
426                        .ml_3p5()
427                        .pl_2p5()
428                        .py_0p5()
429                        .children(self.children.into_iter().enumerate().map(|(ix, item)| {
430                            let id = format!("{}-{}", id, ix);
431                            item.render(id, window, cx).into_any_element()
432                        })),
433                )
434            })
435    }
436}
437
438impl Styled for SidebarMenuItem {
439    fn style(&mut self) -> &mut StyleRefinement {
440        &mut self.style
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn collapsed_icon_item_uses_label_as_tooltip() {
450        let item = SidebarMenuItem::new("Projects")
451            .icon(Icon::default())
452            .collapsed(true);
453
454        assert_eq!(item.collapsed_tooltip().as_deref(), Some("Projects"));
455    }
456
457    #[test]
458    fn expanded_or_iconless_item_has_no_collapsed_tooltip() {
459        let expanded = SidebarMenuItem::new("Projects").icon(Icon::default());
460        let iconless = SidebarMenuItem::new("Projects").collapsed(true);
461
462        assert!(expanded.collapsed_tooltip().is_none());
463        assert!(iconless.collapsed_tooltip().is_none());
464    }
465
466    #[test]
467    fn submenu_caret_is_named_after_the_item() {
468        let item =
469            SidebarMenuItem::new("Projects").children(vec![SidebarMenuItem::new("Overview")]);
470
471        assert_eq!(item.caret_accessibility_label(), "Toggle Projects submenu");
472    }
473}