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    /// Set the context menu for the menu item.
235    pub fn context_menu(
236        mut self,
237        f: impl Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static,
238    ) -> Self {
239        self.context_menu = Some(Rc::new(f));
240        self
241    }
242}
243
244impl FluentBuilder for SidebarMenuItem {}
245
246impl Collapsible for SidebarMenuItem {
247    fn is_collapsed(&self) -> bool {
248        self.collapsed
249    }
250
251    fn collapsed(mut self, collapsed: bool) -> Self {
252        self.collapsed = collapsed;
253        self
254    }
255}
256
257impl SidebarItem for SidebarMenuItem {
258    fn render(
259        self,
260        id: impl Into<ElementId>,
261        window: &mut Window,
262        cx: &mut App,
263    ) -> impl IntoElement {
264        let click_to_open = self.click_to_open;
265        let click_to_toggle = self.click_to_toggle;
266        let default_open = self.default_open;
267        let collapsed_tooltip = self.collapsed_tooltip();
268        let id = id.into();
269        let label_text = self.label.clone();
270        let accessibility_label = self.accessibility_label.clone();
271        let is_submenu = self.is_submenu();
272        let open_state = if is_submenu {
273            Some(window.use_keyed_state(id.clone(), cx, |_, _| default_open))
274        } else {
275            None
276        };
277        let handler = self.handler.clone();
278        let is_collapsed = self.collapsed;
279        let is_active = self.active;
280        let is_hoverable = !is_active && !self.disabled;
281        let is_disabled = self.disabled;
282        let is_open = open_state
283            .as_ref()
284            .map_or(false, |s| !is_collapsed && *s.read(cx));
285
286        div()
287            .id(id.clone())
288            .test_support()
289            .w_full()
290            .child(
291                h_flex()
292                    .size_full()
293                    .id("item")
294                    .role(Role::TreeItem)
295                    .aria_label(accessibility_label.unwrap_or_else(|| label_text.clone()))
296                    .aria_selected(is_active)
297                    .overflow_x_hidden()
298                    .flex_shrink_0()
299                    .p_2()
300                    .gap_x_2()
301                    .rounded(cx.theme().radius)
302                    .text_sm()
303                    .refine_style(&self.style)
304                    .when(is_hoverable, |this| {
305                        this.hover(|this| {
306                            this.bg(cx.theme().sidebar_accent.opacity(0.8))
307                                .text_color(cx.theme().sidebar_accent_foreground)
308                        })
309                    })
310                    .when(is_active, |this| {
311                        this.font_medium()
312                            .bg(cx.theme().tokens.sidebar_accent)
313                            .text_color(cx.theme().sidebar_accent_foreground)
314                    })
315                    .when_some(self.icon.clone(), |this, icon| this.child(icon))
316                    .when(is_collapsed, |this| {
317                        this.justify_center().when(is_active, |this| {
318                            this.bg(cx.theme().tokens.sidebar_accent)
319                                .text_color(cx.theme().sidebar_accent_foreground)
320                        })
321                    })
322                    .when(!is_collapsed, |this| {
323                        this.h_7()
324                            .child(
325                                h_flex()
326                                    .flex_1()
327                                    .gap_x_2()
328                                    .justify_between()
329                                    .overflow_x_hidden()
330                                    .child(
331                                        h_flex()
332                                            .flex_1()
333                                            .overflow_x_hidden()
334                                            .refine_style(&self.label_style)
335                                            .child(self.label.clone()),
336                                    )
337                                    .when_some(self.suffix.clone(), |this, suffix| {
338                                        this.child(suffix(window, cx).into_any_element())
339                                    }),
340                            )
341                            .when_some(open_state.clone(), |this, open_state| {
342                                this.child(
343                                    Button::new("caret")
344                                        .xsmall()
345                                        .ghost()
346                                        .icon(
347                                            Icon::new(IconName::ChevronRight)
348                                                .size_4()
349                                                .when(is_open, |this| {
350                                                    this.rotate(percentage(90. / 360.))
351                                                }),
352                                        )
353                                        .on_click({
354                                            move |_, _, cx| {
355                                                // Avoid trigger item click, just expand/collapse submenu
356                                                cx.stop_propagation();
357                                                open_state.update(cx, |is_open, cx| {
358                                                    *is_open = !*is_open;
359                                                    cx.notify();
360                                                })
361                                            }
362                                        }),
363                                )
364                            })
365                    })
366                    .when(is_disabled, |this| {
367                        this.text_color(cx.theme().muted_foreground)
368                    })
369                    .when(!is_disabled, |this| {
370                        this.on_click({
371                            let open_state = open_state.clone();
372                            move |ev, window, cx| {
373                                if click_to_open {
374                                    if let Some(ref s) = open_state {
375                                        s.update(cx, |is_open: &mut bool, cx| {
376                                            *is_open = true;
377                                            cx.notify();
378                                        });
379                                    }
380                                } else if click_to_toggle {
381                                    if let Some(ref s) = open_state {
382                                        s.update(cx, |is_open: &mut bool, cx| {
383                                            *is_open = !*is_open;
384                                            cx.notify();
385                                        });
386                                    }
387                                }
388                                handler(ev, window, cx)
389                            }
390                        })
391                    })
392                    .map(|this| {
393                        if let Some(tooltip) = collapsed_tooltip {
394                            this.managed_tooltip_at(Placement::Right, move |window, cx| {
395                                Tooltip::new(tooltip.clone()).build(window, cx)
396                            })
397                        } else {
398                            this
399                        }
400                    })
401                    .map(|this| {
402                        if let Some(context_menu) = self.context_menu {
403                            this.context_menu(move |menu, window, cx| {
404                                context_menu(menu, window, cx)
405                            })
406                            .into_any_element()
407                        } else {
408                            this.into_any_element()
409                        }
410                    }),
411            )
412            .when(is_open, |this| {
413                this.child(
414                    v_flex()
415                        .id("submenu")
416                        .border_l_1()
417                        .border_color(cx.theme().sidebar_border)
418                        .gap_1()
419                        .ml_3p5()
420                        .pl_2p5()
421                        .py_0p5()
422                        .children(self.children.into_iter().enumerate().map(|(ix, item)| {
423                            let id = format!("{}-{}", id, ix);
424                            item.render(id, window, cx).into_any_element()
425                        })),
426                )
427            })
428    }
429}
430
431impl Styled for SidebarMenuItem {
432    fn style(&mut self) -> &mut StyleRefinement {
433        &mut self.style
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn collapsed_icon_item_uses_label_as_tooltip() {
443        let item = SidebarMenuItem::new("Projects")
444            .icon(Icon::default())
445            .collapsed(true);
446
447        assert_eq!(item.collapsed_tooltip().as_deref(), Some("Projects"));
448    }
449
450    #[test]
451    fn expanded_or_iconless_item_has_no_collapsed_tooltip() {
452        let expanded = SidebarMenuItem::new("Projects").icon(Icon::default());
453        let iconless = SidebarMenuItem::new("Projects").collapsed(true);
454
455        assert!(expanded.collapsed_tooltip().is_none());
456        assert!(iconless.collapsed_tooltip().is_none());
457    }
458}