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 _, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
13    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    label_style: StyleRefinement,
98    style: StyleRefinement,
99    handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
100    active: bool,
101    default_open: bool,
102    click_to_open: bool,
103    collapsed: bool,
104    click_to_toggle: bool,
105    children: Vec<Self>,
106    suffix: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
107    disabled: bool,
108    context_menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static>>,
109}
110
111impl SidebarMenuItem {
112    /// Create a new [`SidebarMenuItem`] with a label.
113    pub fn new(label: impl Into<SharedString>) -> Self {
114        Self {
115            icon: None,
116            label: label.into(),
117            label_style: StyleRefinement::default(),
118            style: StyleRefinement::default(),
119            handler: Rc::new(|_, _, _| {}),
120            active: false,
121            collapsed: false,
122            default_open: false,
123            click_to_open: false,
124            click_to_toggle: false,
125            children: Vec::new(),
126            suffix: None,
127            disabled: false,
128            context_menu: None,
129        }
130    }
131
132    /// Set the icon for the menu item
133    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
134        self.icon = Some(icon.into());
135        self
136    }
137
138    /// Set the style for the label
139    pub fn label_style(mut self, style: StyleRefinement) -> Self {
140        self.label_style = style;
141        self
142    }
143
144    /// Set the active state of the menu item
145    pub fn active(mut self, active: bool) -> Self {
146        self.active = active;
147        self
148    }
149
150    /// Add a click handler to the menu item
151    pub fn on_click(
152        mut self,
153        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
154    ) -> Self {
155        self.handler = Rc::new(handler);
156        self
157    }
158
159    /// Set the collapsed state of the menu item
160    pub fn collapsed(mut self, collapsed: bool) -> Self {
161        self.collapsed = collapsed;
162        self
163    }
164
165    /// Set the default open state of the Submenu, default is `false`.
166    ///
167    /// This only used on initial render, the internal state will be used afterwards.
168    pub fn default_open(mut self, open: bool) -> Self {
169        self.default_open = open;
170        self
171    }
172
173    /// Set whether clicking the menu item open the submenu.
174    ///
175    /// Default is `false`.
176    ///
177    /// If `false` we only handle open/close via the caret button.
178    pub fn click_to_open(mut self, click_to_open: bool) -> Self {
179        self.click_to_open = click_to_open;
180        self
181    }
182
183    /// Set whether clicking the menu item toggles the submenu.
184    ///
185    /// If click_to_open is `true`, this has no effect.
186    ///
187    /// Default is `false`.
188    pub fn click_to_toggle(mut self, click_to_toggle: bool) -> Self {
189        self.click_to_toggle = click_to_toggle;
190        self
191    }
192
193    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
194        self.children = children.into_iter().map(Into::into).collect();
195        self
196    }
197
198    /// Set the suffix for the menu item.
199    pub fn suffix<F, E>(mut self, builder: F) -> Self
200    where
201        F: Fn(&mut Window, &mut App) -> E + 'static,
202        E: IntoElement,
203    {
204        self.suffix = Some(Rc::new(move |window, cx| {
205            builder(window, cx).into_any_element()
206        }));
207        self
208    }
209
210    /// Set disabled flat for menu item.
211    pub fn disable(mut self, disable: bool) -> Self {
212        self.disabled = disable;
213        self
214    }
215
216    fn is_submenu(&self) -> bool {
217        self.children.len() > 0
218    }
219
220    fn collapsed_tooltip(&self) -> Option<SharedString> {
221        (self.collapsed && self.icon.is_some()).then(|| self.label.clone())
222    }
223
224    /// Set the context menu for the menu item.
225    pub fn context_menu(
226        mut self,
227        f: impl Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static,
228    ) -> Self {
229        self.context_menu = Some(Rc::new(f));
230        self
231    }
232}
233
234impl FluentBuilder for SidebarMenuItem {}
235
236impl Collapsible for SidebarMenuItem {
237    fn is_collapsed(&self) -> bool {
238        self.collapsed
239    }
240
241    fn collapsed(mut self, collapsed: bool) -> Self {
242        self.collapsed = collapsed;
243        self
244    }
245}
246
247impl SidebarItem for SidebarMenuItem {
248    fn render(
249        self,
250        id: impl Into<ElementId>,
251        window: &mut Window,
252        cx: &mut App,
253    ) -> impl IntoElement {
254        let click_to_open = self.click_to_open;
255        let click_to_toggle = self.click_to_toggle;
256        let default_open = self.default_open;
257        let collapsed_tooltip = self.collapsed_tooltip();
258        let id = id.into();
259        let is_submenu = self.is_submenu();
260        let open_state = if is_submenu {
261            Some(window.use_keyed_state(id.clone(), cx, |_, _| default_open))
262        } else {
263            None
264        };
265        let handler = self.handler.clone();
266        let is_collapsed = self.collapsed;
267        let is_active = self.active;
268        let is_hoverable = !is_active && !self.disabled;
269        let is_disabled = self.disabled;
270        let is_open = open_state
271            .as_ref()
272            .map_or(false, |s| !is_collapsed && *s.read(cx));
273
274        div()
275            .id(id.clone())
276            .test_support()
277            .w_full()
278            .child(
279                h_flex()
280                    .size_full()
281                    .id("item")
282                    .overflow_x_hidden()
283                    .flex_shrink_0()
284                    .p_2()
285                    .gap_x_2()
286                    .rounded(cx.theme().radius)
287                    .text_sm()
288                    .refine_style(&self.style)
289                    .when(is_hoverable, |this| {
290                        this.hover(|this| {
291                            this.bg(cx.theme().sidebar_accent.opacity(0.8))
292                                .text_color(cx.theme().sidebar_accent_foreground)
293                        })
294                    })
295                    .when(is_active, |this| {
296                        this.font_medium()
297                            .bg(cx.theme().tokens.sidebar_accent)
298                            .text_color(cx.theme().sidebar_accent_foreground)
299                    })
300                    .when_some(self.icon.clone(), |this, icon| this.child(icon))
301                    .when(is_collapsed, |this| {
302                        this.justify_center().when(is_active, |this| {
303                            this.bg(cx.theme().tokens.sidebar_accent)
304                                .text_color(cx.theme().sidebar_accent_foreground)
305                        })
306                    })
307                    .when(!is_collapsed, |this| {
308                        this.h_7()
309                            .child(
310                                h_flex()
311                                    .flex_1()
312                                    .gap_x_2()
313                                    .justify_between()
314                                    .overflow_x_hidden()
315                                    .child(
316                                        h_flex()
317                                            .flex_1()
318                                            .overflow_x_hidden()
319                                            .refine_style(&self.label_style)
320                                            .child(self.label.clone()),
321                                    )
322                                    .when_some(self.suffix.clone(), |this, suffix| {
323                                        this.child(suffix(window, cx).into_any_element())
324                                    }),
325                            )
326                            .when_some(open_state.clone(), |this, open_state| {
327                                this.child(
328                                    Button::new("caret")
329                                        .xsmall()
330                                        .ghost()
331                                        .icon(
332                                            Icon::new(IconName::ChevronRight)
333                                                .size_4()
334                                                .when(is_open, |this| {
335                                                    this.rotate(percentage(90. / 360.))
336                                                }),
337                                        )
338                                        .on_click({
339                                            move |_, _, cx| {
340                                                // Avoid trigger item click, just expand/collapse submenu
341                                                cx.stop_propagation();
342                                                open_state.update(cx, |is_open, cx| {
343                                                    *is_open = !*is_open;
344                                                    cx.notify();
345                                                })
346                                            }
347                                        }),
348                                )
349                            })
350                    })
351                    .when(is_disabled, |this| {
352                        this.text_color(cx.theme().muted_foreground)
353                    })
354                    .when(!is_disabled, |this| {
355                        this.on_click({
356                            let open_state = open_state.clone();
357                            move |ev, window, cx| {
358                                if click_to_open {
359                                    if let Some(ref s) = open_state {
360                                        s.update(cx, |is_open: &mut bool, cx| {
361                                            *is_open = true;
362                                            cx.notify();
363                                        });
364                                    }
365                                } else if click_to_toggle {
366                                    if let Some(ref s) = open_state {
367                                        s.update(cx, |is_open: &mut bool, cx| {
368                                            *is_open = !*is_open;
369                                            cx.notify();
370                                        });
371                                    }
372                                }
373                                handler(ev, window, cx)
374                            }
375                        })
376                    })
377                    .map(|this| {
378                        if let Some(tooltip) = collapsed_tooltip {
379                            this.managed_tooltip_at(Placement::Right, move |window, cx| {
380                                Tooltip::new(tooltip.clone()).build(window, cx)
381                            })
382                        } else {
383                            this
384                        }
385                    })
386                    .map(|this| {
387                        if let Some(context_menu) = self.context_menu {
388                            this.context_menu(move |menu, window, cx| {
389                                context_menu(menu, window, cx)
390                            })
391                            .into_any_element()
392                        } else {
393                            this.into_any_element()
394                        }
395                    }),
396            )
397            .when(is_open, |this| {
398                this.child(
399                    v_flex()
400                        .id("submenu")
401                        .border_l_1()
402                        .border_color(cx.theme().sidebar_border)
403                        .gap_1()
404                        .ml_3p5()
405                        .pl_2p5()
406                        .py_0p5()
407                        .children(self.children.into_iter().enumerate().map(|(ix, item)| {
408                            let id = format!("{}-{}", id, ix);
409                            item.render(id, window, cx).into_any_element()
410                        })),
411                )
412            })
413    }
414}
415
416impl Styled for SidebarMenuItem {
417    fn style(&mut self) -> &mut StyleRefinement {
418        &mut self.style
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn collapsed_icon_item_uses_label_as_tooltip() {
428        let item = SidebarMenuItem::new("Projects")
429            .icon(Icon::default())
430            .collapsed(true);
431
432        assert_eq!(item.collapsed_tooltip().as_deref(), Some("Projects"));
433    }
434
435    #[test]
436    fn expanded_or_iconless_item_has_no_collapsed_tooltip() {
437        let expanded = SidebarMenuItem::new("Projects").icon(Icon::default());
438        let iconless = SidebarMenuItem::new("Projects").collapsed(true);
439
440        assert!(expanded.collapsed_tooltip().is_none());
441        assert!(iconless.collapsed_tooltip().is_none());
442    }
443}