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 pub fn context_menu(
236 mut self,
237 f: impl Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static,
238 ) -> Self {
239 self.context_menu = Some(Rc::new(f));
240 self
241 }
242}
243
244impl FluentBuilder for SidebarMenuItem {}
245
246impl Collapsible for SidebarMenuItem {
247 fn is_collapsed(&self) -> bool {
248 self.collapsed
249 }
250
251 fn collapsed(mut self, collapsed: bool) -> Self {
252 self.collapsed = collapsed;
253 self
254 }
255}
256
257impl SidebarItem for SidebarMenuItem {
258 fn render(
259 self,
260 id: impl Into<ElementId>,
261 window: &mut Window,
262 cx: &mut App,
263 ) -> impl IntoElement {
264 let click_to_open = self.click_to_open;
265 let click_to_toggle = self.click_to_toggle;
266 let default_open = self.default_open;
267 let collapsed_tooltip = self.collapsed_tooltip();
268 let id = id.into();
269 let label_text = self.label.clone();
270 let accessibility_label = self.accessibility_label.clone();
271 let is_submenu = self.is_submenu();
272 let open_state = if is_submenu {
273 Some(window.use_keyed_state(id.clone(), cx, |_, _| default_open))
274 } else {
275 None
276 };
277 let handler = self.handler.clone();
278 let is_collapsed = self.collapsed;
279 let is_active = self.active;
280 let is_hoverable = !is_active && !self.disabled;
281 let is_disabled = self.disabled;
282 let is_open = open_state
283 .as_ref()
284 .map_or(false, |s| !is_collapsed && *s.read(cx));
285
286 div()
287 .id(id.clone())
288 .test_support()
289 .w_full()
290 .child(
291 h_flex()
292 .size_full()
293 .id("item")
294 .role(Role::TreeItem)
295 .aria_label(accessibility_label.unwrap_or_else(|| label_text.clone()))
296 .aria_selected(is_active)
297 .overflow_x_hidden()
298 .flex_shrink_0()
299 .p_2()
300 .gap_x_2()
301 .rounded(cx.theme().radius)
302 .text_sm()
303 .refine_style(&self.style)
304 .when(is_hoverable, |this| {
305 this.hover(|this| {
306 this.bg(cx.theme().sidebar_accent.opacity(0.8))
307 .text_color(cx.theme().sidebar_accent_foreground)
308 })
309 })
310 .when(is_active, |this| {
311 this.font_medium()
312 .bg(cx.theme().tokens.sidebar_accent)
313 .text_color(cx.theme().sidebar_accent_foreground)
314 })
315 .when_some(self.icon.clone(), |this, icon| this.child(icon))
316 .when(is_collapsed, |this| {
317 this.justify_center().when(is_active, |this| {
318 this.bg(cx.theme().tokens.sidebar_accent)
319 .text_color(cx.theme().sidebar_accent_foreground)
320 })
321 })
322 .when(!is_collapsed, |this| {
323 this.h_7()
324 .child(
325 h_flex()
326 .flex_1()
327 .gap_x_2()
328 .justify_between()
329 .overflow_x_hidden()
330 .child(
331 h_flex()
332 .flex_1()
333 .overflow_x_hidden()
334 .refine_style(&self.label_style)
335 .child(self.label.clone()),
336 )
337 .when_some(self.suffix.clone(), |this, suffix| {
338 this.child(suffix(window, cx).into_any_element())
339 }),
340 )
341 .when_some(open_state.clone(), |this, open_state| {
342 this.child(
343 Button::new("caret")
344 .xsmall()
345 .ghost()
346 .icon(
347 Icon::new(IconName::ChevronRight)
348 .size_4()
349 .when(is_open, |this| {
350 this.rotate(percentage(90. / 360.))
351 }),
352 )
353 .on_click({
354 move |_, _, cx| {
355 cx.stop_propagation();
357 open_state.update(cx, |is_open, cx| {
358 *is_open = !*is_open;
359 cx.notify();
360 })
361 }
362 }),
363 )
364 })
365 })
366 .when(is_disabled, |this| {
367 this.text_color(cx.theme().muted_foreground)
368 })
369 .when(!is_disabled, |this| {
370 this.on_click({
371 let open_state = open_state.clone();
372 move |ev, window, cx| {
373 if click_to_open {
374 if let Some(ref s) = open_state {
375 s.update(cx, |is_open: &mut bool, cx| {
376 *is_open = true;
377 cx.notify();
378 });
379 }
380 } else if click_to_toggle {
381 if let Some(ref s) = open_state {
382 s.update(cx, |is_open: &mut bool, cx| {
383 *is_open = !*is_open;
384 cx.notify();
385 });
386 }
387 }
388 handler(ev, window, cx)
389 }
390 })
391 })
392 .map(|this| {
393 if let Some(tooltip) = collapsed_tooltip {
394 this.managed_tooltip_at(Placement::Right, move |window, cx| {
395 Tooltip::new(tooltip.clone()).build(window, cx)
396 })
397 } else {
398 this
399 }
400 })
401 .map(|this| {
402 if let Some(context_menu) = self.context_menu {
403 this.context_menu(move |menu, window, cx| {
404 context_menu(menu, window, cx)
405 })
406 .into_any_element()
407 } else {
408 this.into_any_element()
409 }
410 }),
411 )
412 .when(is_open, |this| {
413 this.child(
414 v_flex()
415 .id("submenu")
416 .border_l_1()
417 .border_color(cx.theme().sidebar_border)
418 .gap_1()
419 .ml_3p5()
420 .pl_2p5()
421 .py_0p5()
422 .children(self.children.into_iter().enumerate().map(|(ix, item)| {
423 let id = format!("{}-{}", id, ix);
424 item.render(id, window, cx).into_any_element()
425 })),
426 )
427 })
428 }
429}
430
431impl Styled for SidebarMenuItem {
432 fn style(&mut self) -> &mut StyleRefinement {
433 &mut self.style
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn collapsed_icon_item_uses_label_as_tooltip() {
443 let item = SidebarMenuItem::new("Projects")
444 .icon(Icon::default())
445 .collapsed(true);
446
447 assert_eq!(item.collapsed_tooltip().as_deref(), Some("Projects"));
448 }
449
450 #[test]
451 fn expanded_or_iconless_item_has_no_collapsed_tooltip() {
452 let expanded = SidebarMenuItem::new("Projects").icon(Icon::default());
453 let iconless = SidebarMenuItem::new("Projects").collapsed(true);
454
455 assert!(expanded.collapsed_tooltip().is_none());
456 assert!(iconless.collapsed_tooltip().is_none());
457 }
458}