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 _, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement,
13 Styled, Window, div, percentage, prelude::FluentBuilder,
14};
15use gpui_base::TestSupportExt as _;
16use std::rc::Rc;
17
18#[derive(Clone)]
20pub struct SidebarMenu {
21 style: StyleRefinement,
22 collapsed: bool,
23 items: Vec<SidebarMenuItem>,
24}
25
26impl SidebarMenu {
27 pub fn new() -> Self {
29 Self {
30 style: StyleRefinement::default(),
31 items: Vec::new(),
32 collapsed: false,
33 }
34 }
35
36 pub fn child(mut self, child: impl Into<SidebarMenuItem>) -> Self {
40 self.items.push(child.into());
41 self
42 }
43
44 pub fn children(
46 mut self,
47 children: impl IntoIterator<Item = impl Into<SidebarMenuItem>>,
48 ) -> Self {
49 self.items = children.into_iter().map(Into::into).collect();
50 self
51 }
52}
53
54impl Collapsible for SidebarMenu {
55 fn is_collapsed(&self) -> bool {
56 self.collapsed
57 }
58
59 fn collapsed(mut self, collapsed: bool) -> Self {
60 self.collapsed = collapsed;
61 self
62 }
63}
64
65impl SidebarItem for SidebarMenu {
66 fn render(
67 self,
68 id: impl Into<ElementId>,
69 window: &mut Window,
70 cx: &mut App,
71 ) -> impl IntoElement {
72 let id = id.into();
73
74 v_flex()
75 .gap_2()
76 .refine_style(&self.style)
77 .children(self.items.into_iter().enumerate().map(|(ix, item)| {
78 let id = SharedString::from(format!("{}-{}", id, ix));
79 item.collapsed(self.collapsed)
80 .render(id, window, cx)
81 .into_any_element()
82 }))
83 }
84}
85
86impl Styled for SidebarMenu {
87 fn style(&mut self) -> &mut StyleRefinement {
88 &mut self.style
89 }
90}
91
92#[derive(Clone)]
94pub struct SidebarMenuItem {
95 icon: Option<Icon>,
96 label: SharedString,
97 accessibility_label: Option<SharedString>,
98 label_style: StyleRefinement,
99 style: StyleRefinement,
100 handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
101 active: bool,
102 default_open: bool,
103 click_to_open: bool,
104 collapsed: bool,
105 click_to_toggle: bool,
106 children: Vec<Self>,
107 suffix: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
108 disabled: bool,
109 context_menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static>>,
110}
111
112impl SidebarMenuItem {
113 pub fn new(label: impl Into<SharedString>) -> Self {
115 Self {
116 icon: None,
117 label: label.into(),
118 accessibility_label: None,
119 label_style: StyleRefinement::default(),
120 style: StyleRefinement::default(),
121 handler: Rc::new(|_, _, _| {}),
122 active: false,
123 collapsed: false,
124 default_open: false,
125 click_to_open: false,
126 click_to_toggle: false,
127 children: Vec::new(),
128 suffix: None,
129 disabled: false,
130 context_menu: None,
131 }
132 }
133
134 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
136 self.icon = Some(icon.into());
137 self
138 }
139
140 pub fn label_style(mut self, style: StyleRefinement) -> Self {
142 self.label_style = style;
143 self
144 }
145
146 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
150 self.accessibility_label = Some(label.into());
151 self
152 }
153
154 pub fn active(mut self, active: bool) -> Self {
156 self.active = active;
157 self
158 }
159
160 pub fn on_click(
162 mut self,
163 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
164 ) -> Self {
165 self.handler = Rc::new(handler);
166 self
167 }
168
169 pub fn collapsed(mut self, collapsed: bool) -> Self {
171 self.collapsed = collapsed;
172 self
173 }
174
175 pub fn default_open(mut self, open: bool) -> Self {
179 self.default_open = open;
180 self
181 }
182
183 pub fn click_to_open(mut self, click_to_open: bool) -> Self {
189 self.click_to_open = click_to_open;
190 self
191 }
192
193 pub fn click_to_toggle(mut self, click_to_toggle: bool) -> Self {
199 self.click_to_toggle = click_to_toggle;
200 self
201 }
202
203 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
204 self.children = children.into_iter().map(Into::into).collect();
205 self
206 }
207
208 pub fn suffix<F, E>(mut self, builder: F) -> Self
210 where
211 F: Fn(&mut Window, &mut App) -> E + 'static,
212 E: IntoElement,
213 {
214 self.suffix = Some(Rc::new(move |window, cx| {
215 builder(window, cx).into_any_element()
216 }));
217 self
218 }
219
220 pub fn disable(mut self, disable: bool) -> Self {
222 self.disabled = disable;
223 self
224 }
225
226 fn is_submenu(&self) -> bool {
227 self.children.len() > 0
228 }
229
230 fn collapsed_tooltip(&self) -> Option<SharedString> {
231 (self.collapsed && self.icon.is_some()).then(|| self.label.clone())
232 }
233
234 fn caret_accessibility_label(&self) -> SharedString {
237 format!("Toggle {} submenu", self.label).into()
238 }
239
240 pub fn context_menu(
242 mut self,
243 f: impl Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static,
244 ) -> Self {
245 self.context_menu = Some(Rc::new(f));
246 self
247 }
248}
249
250impl FluentBuilder for SidebarMenuItem {}
251
252impl Collapsible for SidebarMenuItem {
253 fn is_collapsed(&self) -> bool {
254 self.collapsed
255 }
256
257 fn collapsed(mut self, collapsed: bool) -> Self {
258 self.collapsed = collapsed;
259 self
260 }
261}
262
263impl SidebarItem for SidebarMenuItem {
264 fn render(
265 self,
266 id: impl Into<ElementId>,
267 window: &mut Window,
268 cx: &mut App,
269 ) -> impl IntoElement {
270 let click_to_open = self.click_to_open;
271 let click_to_toggle = self.click_to_toggle;
272 let default_open = self.default_open;
273 let collapsed_tooltip = self.collapsed_tooltip();
274 let id = id.into();
275 let label_text = self.label.clone();
276 let accessibility_label = self.accessibility_label.clone();
277 let is_submenu = self.is_submenu();
278 let open_state = if is_submenu {
279 Some(window.use_keyed_state(id.clone(), cx, |_, _| default_open))
280 } else {
281 None
282 };
283 let handler = self.handler.clone();
284 let is_collapsed = self.collapsed;
285 let is_active = self.active;
286 let is_hoverable = !is_active && !self.disabled;
287 let is_disabled = self.disabled;
288 let is_open = open_state
289 .as_ref()
290 .map_or(false, |s| !is_collapsed && *s.read(cx));
291
292 div()
293 .id(id.clone())
294 .test_support()
295 .w_full()
296 .child(
297 h_flex()
298 .size_full()
299 .id("item")
300 .role(Role::TreeItem)
301 .aria_label(accessibility_label.unwrap_or_else(|| label_text.clone()))
302 .aria_selected(is_active)
303 .overflow_x_hidden()
304 .flex_shrink_0()
305 .p_2()
306 .gap_x_2()
307 .rounded(cx.theme().radius)
308 .text_sm()
309 .refine_style(&self.style)
310 .when(is_hoverable, |this| {
311 this.hover(|this| {
312 this.bg(cx.theme().sidebar_accent.opacity(0.8))
313 .text_color(cx.theme().sidebar_accent_foreground)
314 })
315 })
316 .when(is_active, |this| {
317 this.font_medium()
318 .bg(cx.theme().tokens.sidebar_accent)
319 .text_color(cx.theme().sidebar_accent_foreground)
320 })
321 .when_some(self.icon.clone(), |this, icon| this.child(icon))
322 .when(is_collapsed, |this| {
323 this.justify_center().when(is_active, |this| {
324 this.bg(cx.theme().tokens.sidebar_accent)
325 .text_color(cx.theme().sidebar_accent_foreground)
326 })
327 })
328 .when(!is_collapsed, |this| {
329 this.h_7()
330 .child(
331 h_flex()
332 .flex_1()
333 .gap_x_2()
334 .justify_between()
335 .overflow_x_hidden()
336 .child(
337 h_flex()
338 .flex_1()
339 .overflow_x_hidden()
340 .refine_style(&self.label_style)
341 .child(self.label.clone()),
342 )
343 .when_some(self.suffix.clone(), |this, suffix| {
344 this.child(suffix(window, cx).into_any_element())
345 }),
346 )
347 .when_some(open_state.clone(), |this, open_state| {
348 this.child(
349 Button::new("caret")
350 .xsmall()
351 .ghost()
352 .accessibility_label(self.caret_accessibility_label())
353 .icon(
354 Icon::new(IconName::ChevronRight)
355 .size_4()
356 .when(is_open, |this| {
357 this.rotate(percentage(90. / 360.))
358 }),
359 )
360 .on_click({
361 move |_, _, cx| {
362 cx.stop_propagation();
364 open_state.update(cx, |is_open, cx| {
365 *is_open = !*is_open;
366 cx.notify();
367 })
368 }
369 }),
370 )
371 })
372 })
373 .when(is_disabled, |this| {
374 this.text_color(cx.theme().muted_foreground)
375 })
376 .when(!is_disabled, |this| {
377 this.on_click({
378 let open_state = open_state.clone();
379 move |ev, window, cx| {
380 if click_to_open {
381 if let Some(ref s) = open_state {
382 s.update(cx, |is_open: &mut bool, cx| {
383 *is_open = true;
384 cx.notify();
385 });
386 }
387 } else if click_to_toggle {
388 if let Some(ref s) = open_state {
389 s.update(cx, |is_open: &mut bool, cx| {
390 *is_open = !*is_open;
391 cx.notify();
392 });
393 }
394 }
395 handler(ev, window, cx)
396 }
397 })
398 })
399 .map(|this| {
400 if let Some(tooltip) = collapsed_tooltip {
401 this.managed_tooltip_at(Placement::Right, move |window, cx| {
402 Tooltip::new(tooltip.clone()).build(window, cx)
403 })
404 } else {
405 this
406 }
407 })
408 .map(|this| {
409 if let Some(context_menu) = self.context_menu {
410 this.context_menu(move |menu, window, cx| {
411 context_menu(menu, window, cx)
412 })
413 .into_any_element()
414 } else {
415 this.into_any_element()
416 }
417 }),
418 )
419 .when(is_open, |this| {
420 this.child(
421 v_flex()
422 .id("submenu")
423 .border_l_1()
424 .border_color(cx.theme().sidebar_border)
425 .gap_1()
426 .ml_3p5()
427 .pl_2p5()
428 .py_0p5()
429 .children(self.children.into_iter().enumerate().map(|(ix, item)| {
430 let id = format!("{}-{}", id, ix);
431 item.render(id, window, cx).into_any_element()
432 })),
433 )
434 })
435 }
436}
437
438impl Styled for SidebarMenuItem {
439 fn style(&mut self) -> &mut StyleRefinement {
440 &mut self.style
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn collapsed_icon_item_uses_label_as_tooltip() {
450 let item = SidebarMenuItem::new("Projects")
451 .icon(Icon::default())
452 .collapsed(true);
453
454 assert_eq!(item.collapsed_tooltip().as_deref(), Some("Projects"));
455 }
456
457 #[test]
458 fn expanded_or_iconless_item_has_no_collapsed_tooltip() {
459 let expanded = SidebarMenuItem::new("Projects").icon(Icon::default());
460 let iconless = SidebarMenuItem::new("Projects").collapsed(true);
461
462 assert!(expanded.collapsed_tooltip().is_none());
463 assert!(iconless.collapsed_tooltip().is_none());
464 }
465
466 #[test]
467 fn submenu_caret_is_named_after_the_item() {
468 let item =
469 SidebarMenuItem::new("Projects").children(vec![SidebarMenuItem::new("Overview")]);
470
471 assert_eq!(item.caret_accessibility_label(), "Toggle Projects submenu");
472 }
473}