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 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 label_style: StyleRefinement,
98 style: StyleRefinement,
99 handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
100 active: bool,
101 default_open: bool,
102 click_to_open: bool,
103 collapsed: bool,
104 click_to_toggle: bool,
105 children: Vec<Self>,
106 suffix: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
107 disabled: bool,
108 context_menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static>>,
109}
110
111impl SidebarMenuItem {
112 pub fn new(label: impl Into<SharedString>) -> Self {
114 Self {
115 icon: None,
116 label: label.into(),
117 label_style: StyleRefinement::default(),
118 style: StyleRefinement::default(),
119 handler: Rc::new(|_, _, _| {}),
120 active: false,
121 collapsed: false,
122 default_open: false,
123 click_to_open: false,
124 click_to_toggle: false,
125 children: Vec::new(),
126 suffix: None,
127 disabled: false,
128 context_menu: None,
129 }
130 }
131
132 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
134 self.icon = Some(icon.into());
135 self
136 }
137
138 pub fn label_style(mut self, style: StyleRefinement) -> Self {
140 self.label_style = style;
141 self
142 }
143
144 pub fn active(mut self, active: bool) -> Self {
146 self.active = active;
147 self
148 }
149
150 pub fn on_click(
152 mut self,
153 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
154 ) -> Self {
155 self.handler = Rc::new(handler);
156 self
157 }
158
159 pub fn collapsed(mut self, collapsed: bool) -> Self {
161 self.collapsed = collapsed;
162 self
163 }
164
165 pub fn default_open(mut self, open: bool) -> Self {
169 self.default_open = open;
170 self
171 }
172
173 pub fn click_to_open(mut self, click_to_open: bool) -> Self {
179 self.click_to_open = click_to_open;
180 self
181 }
182
183 pub fn click_to_toggle(mut self, click_to_toggle: bool) -> Self {
189 self.click_to_toggle = click_to_toggle;
190 self
191 }
192
193 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
194 self.children = children.into_iter().map(Into::into).collect();
195 self
196 }
197
198 pub fn suffix<F, E>(mut self, builder: F) -> Self
200 where
201 F: Fn(&mut Window, &mut App) -> E + 'static,
202 E: IntoElement,
203 {
204 self.suffix = Some(Rc::new(move |window, cx| {
205 builder(window, cx).into_any_element()
206 }));
207 self
208 }
209
210 pub fn disable(mut self, disable: bool) -> Self {
212 self.disabled = disable;
213 self
214 }
215
216 fn is_submenu(&self) -> bool {
217 self.children.len() > 0
218 }
219
220 fn collapsed_tooltip(&self) -> Option<SharedString> {
221 (self.collapsed && self.icon.is_some()).then(|| self.label.clone())
222 }
223
224 pub fn context_menu(
226 mut self,
227 f: impl Fn(PopupMenu, &mut Window, &mut App) -> PopupMenu + 'static,
228 ) -> Self {
229 self.context_menu = Some(Rc::new(f));
230 self
231 }
232}
233
234impl FluentBuilder for SidebarMenuItem {}
235
236impl Collapsible for SidebarMenuItem {
237 fn is_collapsed(&self) -> bool {
238 self.collapsed
239 }
240
241 fn collapsed(mut self, collapsed: bool) -> Self {
242 self.collapsed = collapsed;
243 self
244 }
245}
246
247impl SidebarItem for SidebarMenuItem {
248 fn render(
249 self,
250 id: impl Into<ElementId>,
251 window: &mut Window,
252 cx: &mut App,
253 ) -> impl IntoElement {
254 let click_to_open = self.click_to_open;
255 let click_to_toggle = self.click_to_toggle;
256 let default_open = self.default_open;
257 let collapsed_tooltip = self.collapsed_tooltip();
258 let id = id.into();
259 let is_submenu = self.is_submenu();
260 let open_state = if is_submenu {
261 Some(window.use_keyed_state(id.clone(), cx, |_, _| default_open))
262 } else {
263 None
264 };
265 let handler = self.handler.clone();
266 let is_collapsed = self.collapsed;
267 let is_active = self.active;
268 let is_hoverable = !is_active && !self.disabled;
269 let is_disabled = self.disabled;
270 let is_open = open_state
271 .as_ref()
272 .map_or(false, |s| !is_collapsed && *s.read(cx));
273
274 div()
275 .id(id.clone())
276 .test_support()
277 .w_full()
278 .child(
279 h_flex()
280 .size_full()
281 .id("item")
282 .overflow_x_hidden()
283 .flex_shrink_0()
284 .p_2()
285 .gap_x_2()
286 .rounded(cx.theme().radius)
287 .text_sm()
288 .refine_style(&self.style)
289 .when(is_hoverable, |this| {
290 this.hover(|this| {
291 this.bg(cx.theme().sidebar_accent.opacity(0.8))
292 .text_color(cx.theme().sidebar_accent_foreground)
293 })
294 })
295 .when(is_active, |this| {
296 this.font_medium()
297 .bg(cx.theme().tokens.sidebar_accent)
298 .text_color(cx.theme().sidebar_accent_foreground)
299 })
300 .when_some(self.icon.clone(), |this, icon| this.child(icon))
301 .when(is_collapsed, |this| {
302 this.justify_center().when(is_active, |this| {
303 this.bg(cx.theme().tokens.sidebar_accent)
304 .text_color(cx.theme().sidebar_accent_foreground)
305 })
306 })
307 .when(!is_collapsed, |this| {
308 this.h_7()
309 .child(
310 h_flex()
311 .flex_1()
312 .gap_x_2()
313 .justify_between()
314 .overflow_x_hidden()
315 .child(
316 h_flex()
317 .flex_1()
318 .overflow_x_hidden()
319 .refine_style(&self.label_style)
320 .child(self.label.clone()),
321 )
322 .when_some(self.suffix.clone(), |this, suffix| {
323 this.child(suffix(window, cx).into_any_element())
324 }),
325 )
326 .when_some(open_state.clone(), |this, open_state| {
327 this.child(
328 Button::new("caret")
329 .xsmall()
330 .ghost()
331 .icon(
332 Icon::new(IconName::ChevronRight)
333 .size_4()
334 .when(is_open, |this| {
335 this.rotate(percentage(90. / 360.))
336 }),
337 )
338 .on_click({
339 move |_, _, cx| {
340 cx.stop_propagation();
342 open_state.update(cx, |is_open, cx| {
343 *is_open = !*is_open;
344 cx.notify();
345 })
346 }
347 }),
348 )
349 })
350 })
351 .when(is_disabled, |this| {
352 this.text_color(cx.theme().muted_foreground)
353 })
354 .when(!is_disabled, |this| {
355 this.on_click({
356 let open_state = open_state.clone();
357 move |ev, window, cx| {
358 if click_to_open {
359 if let Some(ref s) = open_state {
360 s.update(cx, |is_open: &mut bool, cx| {
361 *is_open = true;
362 cx.notify();
363 });
364 }
365 } else if click_to_toggle {
366 if let Some(ref s) = open_state {
367 s.update(cx, |is_open: &mut bool, cx| {
368 *is_open = !*is_open;
369 cx.notify();
370 });
371 }
372 }
373 handler(ev, window, cx)
374 }
375 })
376 })
377 .map(|this| {
378 if let Some(tooltip) = collapsed_tooltip {
379 this.managed_tooltip_at(Placement::Right, move |window, cx| {
380 Tooltip::new(tooltip.clone()).build(window, cx)
381 })
382 } else {
383 this
384 }
385 })
386 .map(|this| {
387 if let Some(context_menu) = self.context_menu {
388 this.context_menu(move |menu, window, cx| {
389 context_menu(menu, window, cx)
390 })
391 .into_any_element()
392 } else {
393 this.into_any_element()
394 }
395 }),
396 )
397 .when(is_open, |this| {
398 this.child(
399 v_flex()
400 .id("submenu")
401 .border_l_1()
402 .border_color(cx.theme().sidebar_border)
403 .gap_1()
404 .ml_3p5()
405 .pl_2p5()
406 .py_0p5()
407 .children(self.children.into_iter().enumerate().map(|(ix, item)| {
408 let id = format!("{}-{}", id, ix);
409 item.render(id, window, cx).into_any_element()
410 })),
411 )
412 })
413 }
414}
415
416impl Styled for SidebarMenuItem {
417 fn style(&mut self) -> &mut StyleRefinement {
418 &mut self.style
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn collapsed_icon_item_uses_label_as_tooltip() {
428 let item = SidebarMenuItem::new("Projects")
429 .icon(Icon::default())
430 .collapsed(true);
431
432 assert_eq!(item.collapsed_tooltip().as_deref(), Some("Projects"));
433 }
434
435 #[test]
436 fn expanded_or_iconless_item_has_no_collapsed_tooltip() {
437 let expanded = SidebarMenuItem::new("Projects").icon(Icon::default());
438 let iconless = SidebarMenuItem::new("Projects").collapsed(true);
439
440 assert!(expanded.collapsed_tooltip().is_none());
441 assert!(iconless.collapsed_tooltip().is_none());
442 }
443}