1use crate::gpui_compat::element_id;
2use crate::{Popover, motion::pop_in};
3use gpui::{
4 AnyElement, App, Context, IntoElement, Render, SharedString, Window, div, prelude::*, px,
5};
6use liora_core::{Config, Placement};
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use std::collections::HashSet;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum MenuMode {
13 #[default]
14 Vertical,
15 Horizontal,
16}
17
18pub enum MenuNode {
19 Item(MenuItem),
20 SubMenu(SubMenu),
21 Group(MenuItemGroup),
22}
23
24pub struct MenuItem {
25 pub id: SharedString,
26 pub label: SharedString,
27 pub icon: Option<IconName>,
28}
29
30pub struct SubMenu {
31 pub id: SharedString,
32 pub label: SharedString,
33 pub icon: Option<IconName>,
34 pub children: Vec<MenuNode>,
35}
36
37pub struct MenuItemGroup {
38 pub title: SharedString,
39 pub children: Vec<MenuNode>,
40}
41
42pub struct Menu {
43 id: SharedString,
44 mode: MenuMode,
45 is_collapsed: bool,
46 active_index: Option<SharedString>,
47 opened_submenus: HashSet<SharedString>,
48 items: Vec<MenuNode>,
49 on_select: Option<Box<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
50 close_on_escape: bool,
51}
52
53impl Menu {
54 pub fn new() -> Self {
55 Self {
56 id: liora_core::unique_id("menu"),
57 mode: MenuMode::Vertical,
58 is_collapsed: false,
59 active_index: None,
60 opened_submenus: HashSet::new(),
61 items: vec![],
62 on_select: None,
63 close_on_escape: true,
64 }
65 }
66
67 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
68 self.id = id.into();
69 self
70 }
71
72 pub fn mode(mut self, mode: MenuMode) -> Self {
73 self.mode = mode;
74 self
75 }
76
77 pub fn collapse(mut self, collapsed: bool) -> Self {
78 self.is_collapsed = collapsed;
79 self
80 }
81
82 pub fn default_active(mut self, index: impl Into<SharedString>) -> Self {
83 self.active_index = Some(index.into());
84 self
85 }
86
87 pub fn on_select(mut self, f: impl Fn(SharedString, &mut Window, &mut App) + 'static) -> Self {
88 self.on_select = Some(Box::new(f));
89 self
90 }
91
92 pub fn close_on_escape(mut self, close: bool) -> Self {
93 self.close_on_escape = close;
94 self
95 }
96
97 pub fn item(
98 mut self,
99 id: impl Into<SharedString>,
100 label: impl Into<SharedString>,
101 icon: Option<IconName>,
102 ) -> Self {
103 self.items.push(MenuNode::Item(MenuItem {
104 id: id.into(),
105 label: label.into(),
106 icon,
107 }));
108 self
109 }
110
111 pub fn submenu<F>(
112 mut self,
113 id: impl Into<SharedString>,
114 label: impl Into<SharedString>,
115 icon: Option<IconName>,
116 f: F,
117 ) -> Self
118 where
119 F: FnOnce(SubMenuBuilder) -> SubMenuBuilder,
120 {
121 let builder = SubMenuBuilder {
122 id: id.into(),
123 label: label.into(),
124 icon,
125 children: vec![],
126 };
127 let result = f(builder);
128 self.items.push(MenuNode::SubMenu(SubMenu {
129 id: result.id,
130 label: result.label,
131 icon: result.icon,
132 children: result.children,
133 }));
134 self
135 }
136
137 pub fn group<F>(mut self, title: impl Into<SharedString>, f: F) -> Self
138 where
139 F: FnOnce(MenuGroupBuilder) -> MenuGroupBuilder,
140 {
141 let builder = MenuGroupBuilder {
142 title: title.into(),
143 children: vec![],
144 };
145 let result = f(builder);
146 self.items.push(MenuNode::Group(MenuItemGroup {
147 title: result.title,
148 children: result.children,
149 }));
150 self
151 }
152
153 fn toggle_submenu(&mut self, id: SharedString, cx: &mut Context<Self>) {
154 if self.opened_submenus.contains(&id) {
155 self.opened_submenus.remove(&id);
156 } else {
157 self.opened_submenus.insert(id);
158 }
159 cx.notify();
160 }
161
162 fn select_item(&mut self, id: SharedString, window: &mut Window, cx: &mut App) {
163 self.active_index = Some(id.clone());
164 if let Some(on_select) = &self.on_select {
165 (on_select)(id, window, cx);
166 }
167 }
168
169 fn render_node(
170 &self,
171 node: &MenuNode,
172 depth: u32,
173 theme: &liora_theme::Theme,
174 cx: &Context<Self>,
175 ) -> AnyElement {
176 match self.mode {
177 MenuMode::Vertical => match node {
178 MenuNode::Item(item) => self.render_vertical_item(item, depth, theme, cx),
179 MenuNode::SubMenu(submenu) => {
180 self.render_vertical_submenu(submenu, depth, theme, cx)
181 }
182 MenuNode::Group(group) => self.render_vertical_group(group, depth, theme, cx),
183 },
184 MenuMode::Horizontal => match node {
185 MenuNode::Item(item) => self.render_horizontal_item(item, theme, cx),
186 MenuNode::SubMenu(submenu) => self.render_horizontal_submenu(submenu, theme, cx),
187 MenuNode::Group(group) => self.render_vertical_group(group, depth, theme, cx),
188 },
189 }
190 }
191
192 fn render_vertical_item(
193 &self,
194 item: &MenuItem,
195 depth: u32,
196 theme: &liora_theme::Theme,
197 cx: &Context<Self>,
198 ) -> AnyElement {
199 let id = item.id.clone();
200 let is_active = self.active_index.as_ref() == Some(&id);
201 let item_color = if is_active {
202 theme.primary.base
203 } else {
204 theme.neutral.text_1
205 };
206 let padding_left = if self.is_collapsed {
207 px(0.0)
208 } else {
209 px(20.0 + (depth as f32 * 20.0))
210 };
211
212 div()
213 .id(element_id(format!("{}-item-{}", self.id, id)))
214 .cursor_pointer()
215 .flex()
216 .flex_row()
217 .items_center()
218 .justify_center()
219 .when(!self.is_collapsed, |s| s.justify_start())
220 .h(px(50.0))
221 .pl(padding_left)
222 .pr(if self.is_collapsed { px(0.0) } else { px(16.0) })
223 .text_color(item_color)
224 .bg(if is_active {
225 theme.primary.base.opacity(0.1)
226 } else {
227 gpui::transparent_black()
228 })
229 .hover(|s| s.bg(theme.neutral.hover))
230 .on_click(cx.listener(move |this, _, window, cx| {
231 this.select_item(id.clone(), window, cx);
232 cx.notify();
233 }))
234 .when_some(item.icon, |s, icon| {
235 s.child(Icon::new(icon).size(px(18.0)).color(item_color))
236 })
237 .when(!self.is_collapsed, |s| {
238 s.child(div().ml_2().text_sm().child(item.label.clone()))
239 })
240 .into_any_element()
241 }
242
243 fn render_vertical_submenu(
244 &self,
245 submenu: &SubMenu,
246 depth: u32,
247 theme: &liora_theme::Theme,
248 cx: &Context<Self>,
249 ) -> AnyElement {
250 let id = submenu.id.clone();
251 let is_open = self.opened_submenus.contains(&id);
252 let submenu_color = theme.neutral.text_1;
253 let padding_left = if self.is_collapsed {
254 px(0.0)
255 } else {
256 px(20.0 + (depth as f32 * 20.0))
257 };
258
259 if self.is_collapsed {
260 let menu_handle = cx.entity().clone();
261 Popover::new(
262 div()
263 .id(element_id(format!("{}-collapsed-submenu-{}", self.id, id)))
264 .cursor_pointer()
265 .flex()
266 .items_center()
267 .justify_center()
268 .h(px(50.0))
269 .w_full()
270 .text_color(submenu_color)
271 .hover(|s| s.bg(theme.neutral.hover))
272 .when_some(submenu.icon, |s, icon| {
273 s.child(Icon::new(icon).size(px(18.0)).color(submenu_color))
274 })
275 .when(submenu.icon.is_none(), |s| {
276 s.child(
277 div().text_sm().child(
278 submenu
279 .label
280 .clone()
281 .to_string()
282 .chars()
283 .next()
284 .unwrap_or('?')
285 .to_string(),
286 ),
287 )
288 }),
289 )
290 .id(format!("{}-collapsed-popover-{}", self.id, id))
291 .close_on_escape(self.close_on_escape)
292 .placement(Placement::RightStart)
293 .content({
294 let popover_id: SharedString =
295 format!("{}-collapsed-popover-{}", self.id, id).into();
296 let children: Vec<MenuItem> = submenu
297 .children
298 .iter()
299 .filter_map(|n| {
300 if let MenuNode::Item(i) = n {
301 Some(MenuItem {
302 id: i.id.clone(),
303 label: i.label.clone(),
304 icon: i.icon,
305 })
306 } else {
307 None
308 }
309 })
310 .collect();
311 let theme = theme.clone();
312 let popover_id = popover_id.clone();
313 move |_window, _cx| {
314 let menu_handle = menu_handle.clone();
315 div()
316 .id(element_id(format!(
317 "menu-sub-popover-content-{}",
318 menu_handle.entity_id()
319 )))
320 .cursor_default()
321 .occlude()
322 .on_hover(|_, _, cx| {
323 cx.stop_propagation();
324 })
325 .on_mouse_move(|_, _, cx| {
326 cx.stop_propagation();
327 })
328 .flex()
329 .flex_col()
330 .p_1()
331 .min_w(px(160.0))
332 .children(children.iter().map(|item| {
333 let id = item.id.clone();
334 let label = item.label.clone();
335 let icon = item.icon;
336 let theme = theme.clone();
337 let menu_handle = menu_handle.clone();
338 let is_active =
339 menu_handle.read(_cx).active_index.as_ref() == Some(&id);
340 let item_color = if is_active {
341 theme.primary.base
342 } else {
343 theme.neutral.text_1
344 };
345 div()
346 .id(element_id(format!(
347 "menu-sub-item-{}-{}",
348 menu_handle.entity_id(),
349 id
350 )))
351 .cursor_pointer()
352 .flex()
353 .flex_row()
354 .items_center()
355 .gap_2()
356 .px_3()
357 .py_2()
358 .rounded(px(theme.radius.sm))
359 .text_color(item_color)
360 .bg(if is_active {
361 theme.primary.base.opacity(0.1)
362 } else {
363 gpui::transparent_black()
364 })
365 .hover(|s| s.bg(theme.neutral.hover))
366 .on_click({
367 let popover_id = popover_id.clone();
368 move |_, window, cx| {
369 let _ = menu_handle.update(cx, |this, cx| {
370 this.select_item(id.clone(), window, cx);
371 cx.notify();
372 });
373 liora_core::clear_popover(&popover_id, cx);
374 }
375 })
376 .when_some(icon, |s, i| {
377 s.child(Icon::new(i).size(px(16.0)).color(item_color))
378 })
379 .child(div().text_sm().child(label))
380 }))
381 }
382 })
383 .into_any_element()
384 } else {
385 let toggle_id = id.clone();
386 div()
387 .flex()
388 .flex_col()
389 .child(
390 div()
391 .id(element_id(format!("{}-submenu-{}", self.id, id)))
392 .cursor_pointer()
393 .flex()
394 .flex_row()
395 .items_center()
396 .justify_between()
397 .gap_2()
398 .h(px(50.0))
399 .pl(padding_left)
400 .pr_4()
401 .text_color(submenu_color)
402 .hover(|s| s.bg(theme.neutral.hover))
403 .on_click(cx.listener(move |this, _, _, cx| {
404 this.toggle_submenu(toggle_id.clone(), cx);
405 }))
406 .child(
407 div()
408 .flex()
409 .flex_row()
410 .items_center()
411 .gap_2()
412 .when_some(submenu.icon, |s, icon| {
413 s.child(Icon::new(icon).size(px(18.0)).color(submenu_color))
414 })
415 .child(div().text_sm().child(submenu.label.clone())),
416 )
417 .child(
418 Icon::new(if is_open {
419 IconName::ChevronDown
420 } else {
421 IconName::ChevronRight
422 })
423 .size(px(14.0))
424 .color(submenu_color),
425 ),
426 )
427 .when(is_open, |s| {
428 s.child(pop_in(
429 element_id(format!("{}-submenu-motion-{}", self.id, id)),
430 div().flex().flex_col().children(
431 submenu
432 .children
433 .iter()
434 .map(|child| self.render_node(child, depth + 1, theme, cx)),
435 ),
436 ))
437 })
438 .into_any_element()
439 }
440 }
441
442 fn render_vertical_group(
443 &self,
444 group: &MenuItemGroup,
445 depth: u32,
446 theme: &liora_theme::Theme,
447 cx: &Context<Self>,
448 ) -> AnyElement {
449 if self.is_collapsed {
450 return div().into_any_element();
451 }
452 let padding_left = px(20.0 + (depth as f32 * 20.0));
453
454 div()
455 .flex()
456 .flex_col()
457 .child(
458 div()
459 .h(px(30.0))
460 .pl(padding_left)
461 .flex()
462 .items_center()
463 .child(
464 div()
465 .text_xs()
466 .text_color(theme.neutral.text_3)
467 .child(group.title.clone()),
468 ),
469 )
470 .children(
471 group
472 .children
473 .iter()
474 .map(|child| self.render_node(child, depth, theme, cx)),
475 )
476 .into_any_element()
477 }
478
479 fn render_horizontal_item(
480 &self,
481 item: &MenuItem,
482 theme: &liora_theme::Theme,
483 cx: &Context<Self>,
484 ) -> AnyElement {
485 let id = item.id.clone();
486 let is_active = self.active_index.as_ref() == Some(&id);
487 let item_color = if is_active {
488 theme.primary.base
489 } else {
490 theme.neutral.text_1
491 };
492
493 div()
494 .id(element_id(format!("{}-horizontal-item-{}", self.id, id)))
495 .cursor_pointer()
496 .flex()
497 .flex_row()
498 .items_center()
499 .gap_2()
500 .h(px(60.0))
501 .px_5()
502 .text_color(item_color)
503 .border_b_2()
504 .border_color(if is_active {
505 theme.primary.base
506 } else {
507 gpui::transparent_black()
508 })
509 .hover(|s| s.bg(theme.neutral.hover))
510 .on_click(cx.listener(move |this, _, window, cx| {
511 this.select_item(id.clone(), window, cx);
512 cx.notify();
513 }))
514 .when_some(item.icon, |s, icon| {
515 s.child(Icon::new(icon).size(px(18.0)).color(item_color))
516 })
517 .child(div().text_sm().child(item.label.clone()))
518 .into_any_element()
519 }
520
521 fn render_horizontal_submenu(
522 &self,
523 submenu: &SubMenu,
524 theme: &liora_theme::Theme,
525 cx: &Context<Self>,
526 ) -> AnyElement {
527 let id = submenu.id.clone();
528 let menu_handle = cx.entity().clone();
529 let submenu_color = theme.neutral.text_1;
530
531 Popover::new(
532 div()
533 .id(element_id(format!("{}-horizontal-submenu-{}", self.id, id)))
534 .cursor_pointer()
535 .flex()
536 .flex_row()
537 .items_center()
538 .gap_1()
539 .h(px(60.0))
540 .px_5()
541 .text_color(submenu_color)
542 .hover(|s| s.bg(theme.neutral.hover))
543 .child(
544 div()
545 .flex()
546 .flex_row()
547 .items_center()
548 .gap_2()
549 .when_some(submenu.icon, |s, icon| {
550 s.child(Icon::new(icon).size(px(18.0)).color(submenu_color))
551 })
552 .child(div().text_sm().child(submenu.label.clone()))
553 .child(
554 Icon::new(IconName::ChevronDown)
555 .size(px(12.0))
556 .color(submenu_color),
557 ),
558 ),
559 )
560 .id(format!("{}-horizontal-popover-{}", self.id, id))
561 .close_on_escape(self.close_on_escape)
562 .placement(Placement::BottomStart)
563 .content({
564 let popover_id: SharedString = format!("{}-horizontal-popover-{}", self.id, id).into();
565 let children: Vec<MenuItem> = submenu
566 .children
567 .iter()
568 .filter_map(|n| {
569 if let MenuNode::Item(i) = n {
570 Some(MenuItem {
571 id: i.id.clone(),
572 label: i.label.clone(),
573 icon: i.icon,
574 })
575 } else {
576 None
577 }
578 })
579 .collect();
580 let theme = theme.clone();
581 let popover_id = popover_id.clone();
582 move |_window, _cx| {
583 let menu_handle = menu_handle.clone();
584 div()
585 .id(element_id(format!(
586 "menu-horiz-popover-content-{}",
587 menu_handle.entity_id()
588 )))
589 .cursor_default()
590 .occlude()
591 .on_hover(|_, _, cx| {
592 cx.stop_propagation();
593 })
594 .on_mouse_move(|_, _, cx| {
595 cx.stop_propagation();
596 })
597 .flex()
598 .flex_col()
599 .p_1()
600 .min_w(px(160.0))
601 .children(children.iter().map(|item| {
602 let id = item.id.clone();
603 let label = item.label.clone();
604 let icon = item.icon;
605 let theme = theme.clone();
606 let menu_handle = menu_handle.clone();
607 let is_active = menu_handle.read(_cx).active_index.as_ref() == Some(&id);
608 let item_color = if is_active {
609 theme.primary.base
610 } else {
611 theme.neutral.text_1
612 };
613 div()
614 .id(element_id(format!(
615 "menu-horiz-sub-item-{}-{}",
616 menu_handle.entity_id(),
617 id
618 )))
619 .cursor_pointer()
620 .flex()
621 .flex_row()
622 .items_center()
623 .gap_2()
624 .px_3()
625 .py_2()
626 .rounded(px(theme.radius.sm))
627 .text_color(item_color)
628 .bg(if is_active {
629 theme.primary.base.opacity(0.1)
630 } else {
631 gpui::transparent_black()
632 })
633 .hover(|s| s.bg(theme.neutral.hover))
634 .on_click({
635 let popover_id = popover_id.clone();
636 move |_, window, cx| {
637 let _ = menu_handle.update(cx, |this, cx| {
638 this.select_item(id.clone(), window, cx);
639 cx.notify();
640 });
641 liora_core::clear_popover(&popover_id, cx);
642 }
643 })
644 .when_some(icon, |s, i| {
645 s.child(Icon::new(i).size(px(16.0)).color(item_color))
646 })
647 .child(div().text_sm().child(label))
648 }))
649 }
650 })
651 .into_any_element()
652 }
653}
654
655pub struct SubMenuBuilder {
656 pub id: SharedString,
657 pub label: SharedString,
658 pub icon: Option<IconName>,
659 pub children: Vec<MenuNode>,
660}
661
662impl SubMenuBuilder {
663 pub fn item(
664 mut self,
665 id: impl Into<SharedString>,
666 label: impl Into<SharedString>,
667 icon: Option<IconName>,
668 ) -> Self {
669 self.children.push(MenuNode::Item(MenuItem {
670 id: id.into(),
671 label: label.into(),
672 icon,
673 }));
674 self
675 }
676
677 pub fn submenu<F>(
678 mut self,
679 id: impl Into<SharedString>,
680 label: impl Into<SharedString>,
681 icon: Option<IconName>,
682 f: F,
683 ) -> Self
684 where
685 F: FnOnce(SubMenuBuilder) -> SubMenuBuilder,
686 {
687 let builder = SubMenuBuilder {
688 id: id.into(),
689 label: label.into(),
690 icon,
691 children: vec![],
692 };
693 let result = f(builder);
694 self.children.push(MenuNode::SubMenu(SubMenu {
695 id: result.id,
696 label: result.label,
697 icon: result.icon,
698 children: result.children,
699 }));
700 self
701 }
702
703 pub fn group<F>(mut self, title: impl Into<SharedString>, f: F) -> Self
704 where
705 F: FnOnce(MenuGroupBuilder) -> MenuGroupBuilder,
706 {
707 let builder = MenuGroupBuilder {
708 title: title.into(),
709 children: vec![],
710 };
711 let result = f(builder);
712 self.children.push(MenuNode::Group(MenuItemGroup {
713 title: result.title,
714 children: result.children,
715 }));
716 self
717 }
718}
719
720pub struct MenuGroupBuilder {
721 pub title: SharedString,
722 pub children: Vec<MenuNode>,
723}
724
725impl MenuGroupBuilder {
726 pub fn item(
727 mut self,
728 id: impl Into<SharedString>,
729 label: impl Into<SharedString>,
730 icon: Option<IconName>,
731 ) -> Self {
732 self.children.push(MenuNode::Item(MenuItem {
733 id: id.into(),
734 label: label.into(),
735 icon,
736 }));
737 self
738 }
739
740 pub fn submenu<F>(
741 mut self,
742 id: impl Into<SharedString>,
743 label: impl Into<SharedString>,
744 icon: Option<IconName>,
745 f: F,
746 ) -> Self
747 where
748 F: FnOnce(SubMenuBuilder) -> SubMenuBuilder,
749 {
750 let builder = SubMenuBuilder {
751 id: id.into(),
752 label: label.into(),
753 icon,
754 children: vec![],
755 };
756 let result = f(builder);
757 self.children.push(MenuNode::SubMenu(SubMenu {
758 id: result.id,
759 label: result.label,
760 icon: result.icon,
761 children: result.children,
762 }));
763 self
764 }
765}
766
767impl Render for Menu {
768 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
769 let theme = cx.global::<Config>().theme.clone();
770
771 div()
772 .flex()
773 .w_full()
774 .bg(theme.neutral.card)
775 .when(self.mode == MenuMode::Vertical, |s| s.flex_col())
776 .when(self.mode == MenuMode::Horizontal, |s| {
777 s.flex_row().border_b_1().border_color(theme.neutral.border)
778 })
779 .children(
780 self.items
781 .iter()
782 .map(|node| self.render_node(node, 0, &theme, cx)),
783 )
784 }
785}