Skip to main content

liora_components/
menu.rs

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}