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