gpui_component/sidebar/
menu.rs

1use crate::{h_flex, v_flex, ActiveTheme as _, Collapsible, Icon, IconName, StyledExt};
2use gpui::{
3    div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId,
4    InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
5    StatefulInteractiveElement as _, Styled as _, Window,
6};
7use std::rc::Rc;
8
9#[derive(IntoElement)]
10pub struct SidebarMenu {
11    collapsed: bool,
12    items: Vec<SidebarMenuItem>,
13}
14
15impl SidebarMenu {
16    pub fn new() -> Self {
17        Self {
18            items: Vec::new(),
19            collapsed: false,
20        }
21    }
22
23    pub fn child(mut self, child: impl Into<SidebarMenuItem>) -> Self {
24        self.items.push(child.into());
25        self
26    }
27
28    pub fn children(
29        mut self,
30        children: impl IntoIterator<Item = impl Into<SidebarMenuItem>>,
31    ) -> Self {
32        self.items = children.into_iter().map(Into::into).collect();
33        self
34    }
35}
36impl Collapsible for SidebarMenu {
37    fn is_collapsed(&self) -> bool {
38        self.collapsed
39    }
40
41    fn collapsed(mut self, collapsed: bool) -> Self {
42        self.collapsed = collapsed;
43        self
44    }
45}
46impl RenderOnce for SidebarMenu {
47    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
48        v_flex().gap_2().children(
49            self.items
50                .into_iter()
51                .enumerate()
52                .map(|(ix, item)| item.id(ix).collapsed(self.collapsed)),
53        )
54    }
55}
56
57/// A sidebar menu item
58#[derive(IntoElement)]
59pub struct SidebarMenuItem {
60    id: ElementId,
61    icon: Option<Icon>,
62    label: SharedString,
63    handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
64    active: bool,
65    collapsed: bool,
66    children: Vec<Self>,
67    suffix: Option<AnyElement>,
68}
69
70impl SidebarMenuItem {
71    /// Create a new SidebarMenuItem with a label
72    pub fn new(label: impl Into<SharedString>) -> Self {
73        Self {
74            id: ElementId::Integer(0),
75            icon: None,
76            label: label.into(),
77            handler: Rc::new(|_, _, _| {}),
78            active: false,
79            collapsed: false,
80            children: Vec::new(),
81            suffix: None,
82        }
83    }
84
85    /// Set the icon for the menu item
86    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
87        self.icon = Some(icon.into());
88        self
89    }
90
91    /// Set id to the menu item.
92    fn id(mut self, id: impl Into<ElementId>) -> Self {
93        self.id = id.into();
94        self
95    }
96
97    /// Set the active state of the menu item
98    pub fn active(mut self, active: bool) -> Self {
99        self.active = active;
100        self
101    }
102
103    /// Add a click handler to the menu item
104    pub fn on_click(
105        mut self,
106        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
107    ) -> Self {
108        self.handler = Rc::new(handler);
109        self
110    }
111
112    /// Set the collapsed state of the menu item
113    pub fn collapsed(mut self, collapsed: bool) -> Self {
114        self.collapsed = collapsed;
115        self
116    }
117
118    pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
119        self.children = children.into_iter().map(Into::into).collect();
120        self
121    }
122
123    /// Set the suffix for the menu item.
124    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
125        self.suffix = Some(suffix.into_any_element());
126        self
127    }
128
129    fn is_submenu(&self) -> bool {
130        self.children.len() > 0
131    }
132
133    fn is_open(&self) -> bool {
134        if self.is_submenu() {
135            self.active
136        } else {
137            false
138        }
139    }
140}
141
142impl RenderOnce for SidebarMenuItem {
143    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
144        let handler = self.handler.clone();
145        let is_collapsed = self.collapsed;
146        let is_active = self.active;
147        let is_open = self.is_open();
148        let is_submenu = self.is_submenu();
149
150        div()
151            .id(self.id.clone())
152            .w_full()
153            .child(
154                h_flex()
155                    .size_full()
156                    .id("item")
157                    .overflow_x_hidden()
158                    .flex_shrink_0()
159                    .p_2()
160                    .gap_x_2()
161                    .rounded(cx.theme().radius)
162                    .text_sm()
163                    .hover(|this| {
164                        if is_active {
165                            return this;
166                        }
167
168                        this.bg(cx.theme().sidebar_accent.opacity(0.8))
169                            .text_color(cx.theme().sidebar_accent_foreground)
170                    })
171                    .when(is_active && !is_submenu, |this| {
172                        this.font_medium()
173                            .bg(cx.theme().sidebar_accent)
174                            .text_color(cx.theme().sidebar_accent_foreground)
175                    })
176                    .when_some(self.icon.clone(), |this, icon| this.child(icon))
177                    .when(is_collapsed, |this| {
178                        this.justify_center().when(is_active, |this| {
179                            this.bg(cx.theme().sidebar_accent)
180                                .text_color(cx.theme().sidebar_accent_foreground)
181                        })
182                    })
183                    .when(!is_collapsed, |this| {
184                        this.h_7()
185                            .child(
186                                h_flex()
187                                    .flex_1()
188                                    .gap_x_2()
189                                    .justify_between()
190                                    .overflow_x_hidden()
191                                    .child(
192                                        h_flex()
193                                            .flex_1()
194                                            .overflow_x_hidden()
195                                            .child(self.label.clone()),
196                                    )
197                                    .when_some(self.suffix, |this, suffix| this.child(suffix)),
198                            )
199                            .when(is_submenu, |this| {
200                                this.child(
201                                    Icon::new(IconName::ChevronRight)
202                                        .size_4()
203                                        .when(is_open, |this| this.rotate(percentage(90. / 360.))),
204                                )
205                            })
206                    })
207                    .on_click(move |ev, window, cx| handler(ev, window, cx)),
208            )
209            .when(is_submenu && is_open && !is_collapsed, |this| {
210                this.child(
211                    v_flex()
212                        .id("submenu")
213                        .border_l_1()
214                        .border_color(cx.theme().sidebar_border)
215                        .gap_1()
216                        .ml_3p5()
217                        .pl_2p5()
218                        .py_0p5()
219                        .children(
220                            self.children
221                                .into_iter()
222                                .enumerate()
223                                .map(|(ix, item)| item.id(ix)),
224                        ),
225                )
226            })
227    }
228}