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#[derive(Clone)]
19pub struct SidebarMenu {
20 style: StyleRefinement,
21 collapsed: bool,
22 items: Vec<SidebarMenuItem>,
23}
24
25impl SidebarMenu {
26 pub fn new() -> Self {
28 Self {
29 style: StyleRefinement::default(),
30 items: Vec::new(),
31 collapsed: false,
32 }
33 }
34
35 pub fn child(mut self, child: impl Into<SidebarMenuItem>) -> Self {
39 self.items.push(child.into());
40 self
41 }
42
43 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#[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 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 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
129 self.icon = Some(icon.into());
130 self
131 }
132
133 pub fn active(mut self, active: bool) -> Self {
135 self.active = active;
136 self
137 }
138
139 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 pub fn collapsed(mut self, collapsed: bool) -> Self {
150 self.collapsed = collapsed;
151 self
152 }
153
154 pub fn default_open(mut self, open: bool) -> Self {
158 self.default_open = open;
159 self
160 }
161
162 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 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 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 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 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 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}