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