Skip to main content

rgpui_component/sidebar/
menu.rs

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