gpui_component/sidebar/
menu.rs

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