Skip to main content

iced_shadcn/
menu_primitives.rs

1use std::borrow::Cow;
2
3use iced::advanced::Renderer as _;
4use iced::advanced::layout;
5use iced::advanced::renderer;
6use iced::advanced::text;
7use iced::advanced::text::Renderer as _;
8use iced::advanced::widget::Tree;
9use iced::advanced::{Clipboard, Layout, Shell, Widget};
10use iced::border::Border;
11use iced::font::Weight;
12use iced::keyboard;
13use iced::mouse;
14use iced::touch;
15use iced::{
16    Background, Color, Element, Event, Font, Length, Point, Rectangle, Shadow, Size, Vector,
17};
18use lucide_icons::Icon as LucideIcon;
19
20use crate::overlay::keyboard as overlay_keyboard;
21use crate::theme::Theme;
22use crate::tokens::{
23    AccentColor, accent_color, accent_foreground, accent_high, accent_soft, accent_soft_foreground,
24};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum MenuContentSize {
28    Size1,
29    Size2,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum MenuContentVariant {
34    Solid,
35    Soft,
36}
37
38#[derive(Clone, Copy, Debug)]
39pub struct MenuContentProps {
40    pub size: MenuContentSize,
41    pub variant: MenuContentVariant,
42    pub color: AccentColor,
43    pub high_contrast: bool,
44    pub show_shadow: bool,
45}
46
47impl Default for MenuContentProps {
48    fn default() -> Self {
49        Self {
50            size: MenuContentSize::Size2,
51            variant: MenuContentVariant::Solid,
52            color: AccentColor::Gray,
53            high_contrast: false,
54            show_shadow: true,
55        }
56    }
57}
58
59impl MenuContentProps {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    pub fn size(mut self, size: MenuContentSize) -> Self {
65        self.size = size;
66        self
67    }
68
69    pub fn variant(mut self, variant: MenuContentVariant) -> Self {
70        self.variant = variant;
71        self
72    }
73
74    pub fn color(mut self, color: AccentColor) -> Self {
75        self.color = color;
76        self
77    }
78
79    pub fn high_contrast(mut self, high_contrast: bool) -> Self {
80        self.high_contrast = high_contrast;
81        self
82    }
83
84    pub fn show_shadow(mut self, show_shadow: bool) -> Self {
85        self.show_shadow = show_shadow;
86        self
87    }
88}
89
90#[derive(Clone, Debug, Default)]
91pub struct MenuItemProps<'a> {
92    pub disabled: bool,
93    pub inset: bool,
94    pub color: Option<AccentColor>,
95    pub shortcut: Option<Cow<'a, str>>,
96}
97
98impl<'a> MenuItemProps<'a> {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn disabled(mut self, disabled: bool) -> Self {
104        self.disabled = disabled;
105        self
106    }
107
108    pub fn inset(mut self, inset: bool) -> Self {
109        self.inset = inset;
110        self
111    }
112
113    pub fn color(mut self, color: AccentColor) -> Self {
114        self.color = Some(color);
115        self
116    }
117
118    pub fn shortcut(mut self, shortcut: impl Into<Cow<'a, str>>) -> Self {
119        self.shortcut = Some(shortcut.into());
120        self
121    }
122}
123
124#[derive(Clone, Debug)]
125pub struct MenuItem<'a, Message> {
126    pub label: Cow<'a, str>,
127    pub on_select: Option<Message>,
128    pub props: MenuItemProps<'a>,
129}
130
131impl<'a, Message> MenuItem<'a, Message> {
132    pub fn new(label: impl Into<Cow<'a, str>>, on_select: Option<Message>) -> Self {
133        Self {
134            label: label.into(),
135            on_select,
136            props: MenuItemProps::new(),
137        }
138    }
139
140    pub fn props(mut self, props: MenuItemProps<'a>) -> Self {
141        self.props = props;
142        self
143    }
144}
145
146#[derive(Clone, Debug)]
147pub struct MenuCheckboxItem<'a, Message> {
148    pub label: Cow<'a, str>,
149    pub checked: bool,
150    pub on_toggle: Option<Message>,
151    pub props: MenuItemProps<'a>,
152}
153
154impl<'a, Message> MenuCheckboxItem<'a, Message> {
155    pub fn new(label: impl Into<Cow<'a, str>>, checked: bool, on_toggle: Option<Message>) -> Self {
156        Self {
157            label: label.into(),
158            checked,
159            on_toggle,
160            props: MenuItemProps::new(),
161        }
162    }
163
164    pub fn props(mut self, props: MenuItemProps<'a>) -> Self {
165        self.props = props;
166        self
167    }
168}
169
170#[derive(Clone, Debug)]
171pub struct MenuRadioItem<'a, Message> {
172    pub label: Cow<'a, str>,
173    pub selected: bool,
174    pub on_select: Option<Message>,
175    pub props: MenuItemProps<'a>,
176}
177
178impl<'a, Message> MenuRadioItem<'a, Message> {
179    pub fn new(label: impl Into<Cow<'a, str>>, selected: bool, on_select: Option<Message>) -> Self {
180        Self {
181            label: label.into(),
182            selected,
183            on_select,
184            props: MenuItemProps::new(),
185        }
186    }
187
188    pub fn props(mut self, props: MenuItemProps<'a>) -> Self {
189        self.props = props;
190        self
191    }
192}
193
194#[derive(Clone, Debug)]
195pub struct MenuSubMenu<'a, Message> {
196    pub label: Cow<'a, str>,
197    pub props: MenuItemProps<'a>,
198    pub entries: Vec<MenuEntry<'a, Message>>,
199}
200
201impl<'a, Message> MenuSubMenu<'a, Message> {
202    pub fn new(label: impl Into<Cow<'a, str>>, entries: Vec<MenuEntry<'a, Message>>) -> Self {
203        Self {
204            label: label.into(),
205            props: MenuItemProps::new(),
206            entries,
207        }
208    }
209
210    pub fn props(mut self, props: MenuItemProps<'a>) -> Self {
211        self.props = props;
212        self
213    }
214}
215
216#[derive(Clone, Debug)]
217pub enum MenuEntry<'a, Message> {
218    Label(Cow<'a, str>),
219    Separator,
220    Item(MenuItem<'a, Message>),
221    CheckboxItem(MenuCheckboxItem<'a, Message>),
222    RadioItem(MenuRadioItem<'a, Message>),
223    SubMenu(MenuSubMenu<'a, Message>),
224}
225
226#[derive(Clone, Copy, Debug)]
227pub(crate) enum MenuKind {
228    Dropdown,
229    Context,
230}
231
232#[derive(Clone, Debug)]
233pub(crate) struct MenuOverlayProps<Message> {
234    pub kind: MenuKind,
235    pub width: Option<u32>,
236    pub offset: f32,
237    pub disabled: bool,
238    pub on_close: Option<Message>,
239}
240
241impl<Message> Default for MenuOverlayProps<Message> {
242    fn default() -> Self {
243        Self {
244            kind: MenuKind::Dropdown,
245            width: None,
246            offset: 4.0,
247            disabled: false,
248            on_close: None,
249        }
250    }
251}
252
253#[derive(Debug, Default)]
254struct MenuState {
255    is_open: bool,
256    open_submenu: Option<usize>,
257    opened_at: Option<Point>,
258    overlay_bounds: Option<Rectangle>,
259    submenu_bounds: Option<Rectangle>,
260    keyboard_modifiers: keyboard::Modifiers,
261    hovered_row: Option<usize>,
262    hovered_sub_row: Option<usize>,
263    overlay: MenuOverlayState,
264}
265
266#[derive(Debug)]
267struct MenuOverlayState {
268    main_tree: Tree,
269    submenu_tree: Tree,
270}
271
272impl Default for MenuOverlayState {
273    fn default() -> Self {
274        Self {
275            main_tree: Tree::empty(),
276            submenu_tree: Tree::empty(),
277        }
278    }
279}
280
281pub(crate) fn menu<'a, Message: Clone + 'a>(
282    trigger: impl Into<Element<'a, Message>>,
283    entries: Vec<MenuEntry<'a, Message>>,
284    content: MenuContentProps,
285    overlay: MenuOverlayProps<Message>,
286    theme: &Theme,
287) -> Menu<'a, Message> {
288    Menu {
289        trigger: trigger.into(),
290        entries,
291        content,
292        overlay,
293        theme: theme.clone(),
294    }
295}
296
297pub(crate) struct Menu<'a, Message> {
298    trigger: Element<'a, Message>,
299    entries: Vec<MenuEntry<'a, Message>>,
300    content: MenuContentProps,
301    overlay: MenuOverlayProps<Message>,
302    theme: Theme,
303}
304
305impl<Message> Widget<Message, iced::Theme, iced::Renderer> for Menu<'_, Message>
306where
307    Message: Clone,
308{
309    fn children(&self) -> Vec<Tree> {
310        vec![Tree::new(&self.trigger)]
311    }
312
313    fn diff(&self, tree: &mut Tree) {
314        tree.diff_children(&[self.trigger.as_widget()]);
315    }
316
317    fn state(&self) -> iced::advanced::widget::tree::State {
318        iced::advanced::widget::tree::State::new(MenuState::default())
319    }
320
321    fn tag(&self) -> iced::advanced::widget::tree::Tag {
322        iced::advanced::widget::tree::Tag::of::<MenuState>()
323    }
324
325    fn size(&self) -> Size<Length> {
326        self.trigger.as_widget().size()
327    }
328
329    fn layout(
330        &mut self,
331        tree: &mut Tree,
332        renderer: &iced::Renderer,
333        limits: &layout::Limits,
334    ) -> layout::Node {
335        self.trigger
336            .as_widget_mut()
337            .layout(&mut tree.children[0], renderer, limits)
338    }
339
340    fn update(
341        &mut self,
342        tree: &mut Tree,
343        event: &Event,
344        layout: Layout<'_>,
345        cursor: mouse::Cursor,
346        renderer: &iced::Renderer,
347        clipboard: &mut dyn Clipboard,
348        shell: &mut Shell<'_, Message>,
349        viewport: &Rectangle,
350    ) {
351        let state = tree.state.downcast_mut::<MenuState>();
352        let was_open = state.is_open;
353        let was_open_submenu = state.open_submenu;
354        let was_opened_at = state.opened_at;
355
356        self.trigger.as_widget_mut().update(
357            &mut tree.children[0],
358            event,
359            layout,
360            cursor,
361            renderer,
362            clipboard,
363            shell,
364            viewport,
365        );
366
367        if self.overlay.disabled {
368            state.is_open = false;
369            state.open_submenu = None;
370            state.overlay_bounds = None;
371            state.submenu_bounds = None;
372            return;
373        }
374
375        let trigger_bounds = layout.bounds();
376
377        match event {
378            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
379            | Event::Touch(touch::Event::FingerPressed { .. })
380                if matches!(self.overlay.kind, MenuKind::Dropdown) =>
381            {
382                let over_trigger = cursor.is_over(trigger_bounds);
383                let over_menu = state
384                    .overlay_bounds
385                    .map(|b| cursor.is_over(b))
386                    .unwrap_or(false);
387                let over_submenu = state
388                    .submenu_bounds
389                    .map(|b| cursor.is_over(b))
390                    .unwrap_or(false);
391
392                if state.is_open {
393                    if over_trigger || (!over_menu && !over_submenu) {
394                        state.is_open = false;
395                        state.open_submenu = None;
396                        shell.capture_event();
397                    }
398                } else if over_trigger {
399                    state.is_open = true;
400                    state.opened_at = None;
401                    shell.capture_event();
402                }
403            }
404            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
405                if matches!(self.overlay.kind, MenuKind::Context) =>
406            {
407                if cursor.is_over(trigger_bounds) {
408                    state.is_open = true;
409                    state.open_submenu = None;
410                    state.opened_at = cursor.position();
411                    shell.capture_event();
412                } else if state.is_open {
413                    let over_menu = state
414                        .overlay_bounds
415                        .map(|b| cursor.is_over(b))
416                        .unwrap_or(false);
417                    let over_submenu = state
418                        .submenu_bounds
419                        .map(|b| cursor.is_over(b))
420                        .unwrap_or(false);
421                    if !over_menu && !over_submenu {
422                        state.is_open = false;
423                        state.open_submenu = None;
424                        shell.capture_event();
425                    }
426                }
427            }
428            Event::Mouse(mouse::Event::ButtonPressed(_))
429            | Event::Touch(touch::Event::FingerPressed { .. }) => {
430                if state.is_open {
431                    let over_menu = state
432                        .overlay_bounds
433                        .map(|b| cursor.is_over(b))
434                        .unwrap_or(false);
435                    let over_submenu = state
436                        .submenu_bounds
437                        .map(|b| cursor.is_over(b))
438                        .unwrap_or(false);
439                    if !over_menu && !over_submenu {
440                        state.is_open = false;
441                        state.open_submenu = None;
442                        shell.capture_event();
443                    }
444                }
445            }
446            Event::Keyboard(keyboard::Event::KeyPressed { .. })
447                if matches!(
448                    overlay_keyboard::command(event),
449                    Some(overlay_keyboard::OverlayCommand::Close)
450                ) =>
451            {
452                if state.is_open {
453                    state.is_open = false;
454                    state.open_submenu = None;
455                    shell.capture_event();
456                }
457            }
458            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
459                state.keyboard_modifiers = *modifiers;
460            }
461            _ => {}
462        }
463
464        if !state.is_open {
465            state.overlay_bounds = None;
466            state.submenu_bounds = None;
467        }
468
469        if was_open
470            && !state.is_open
471            && let Some(on_close) = self.overlay.on_close.clone()
472        {
473            shell.publish(on_close);
474        }
475
476        if was_open != state.is_open
477            || was_open_submenu != state.open_submenu
478            || was_opened_at != state.opened_at
479        {
480            shell.request_redraw();
481        }
482    }
483
484    fn overlay<'b>(
485        &'b mut self,
486        tree: &'b mut Tree,
487        layout: Layout<'_>,
488        renderer: &iced::Renderer,
489        viewport: &Rectangle,
490        translation: Vector,
491    ) -> Option<iced::overlay::Element<'b, Message, iced::Theme, iced::Renderer>> {
492        let state = tree.state.downcast_mut::<MenuState>();
493        if !state.is_open {
494            return None;
495        }
496
497        let font = renderer.default_font();
498        let bounds = layout.bounds();
499        let anchor_position = layout.position() + translation;
500
501        Some(iced::overlay::Element::new(Box::new(MenuOverlay {
502            entries: &self.entries,
503            state,
504            theme: self.theme.clone(),
505            content: self.content,
506            overlay: self.overlay.clone(),
507            viewport: *viewport,
508            font,
509            anchor_position,
510            target_size: Size::new(bounds.width, bounds.height),
511        })))
512    }
513
514    fn mouse_interaction(
515        &self,
516        tree: &Tree,
517        layout: Layout<'_>,
518        cursor: mouse::Cursor,
519        viewport: &Rectangle,
520        renderer: &iced::Renderer,
521    ) -> mouse::Interaction {
522        self.trigger.as_widget().mouse_interaction(
523            &tree.children[0],
524            layout,
525            cursor,
526            viewport,
527            renderer,
528        )
529    }
530
531    fn draw(
532        &self,
533        tree: &Tree,
534        renderer: &mut iced::Renderer,
535        theme: &iced::Theme,
536        style: &renderer::Style,
537        layout: Layout<'_>,
538        cursor: mouse::Cursor,
539        viewport: &Rectangle,
540    ) {
541        self.trigger.as_widget().draw(
542            &tree.children[0],
543            renderer,
544            theme,
545            style,
546            layout,
547            cursor,
548            viewport,
549        );
550    }
551}
552
553impl<'a, Message: Clone + 'a> From<Menu<'a, Message>> for Element<'a, Message> {
554    fn from(widget: Menu<'a, Message>) -> Element<'a, Message> {
555        Element::new(widget)
556    }
557}
558
559struct MenuOverlay<'a, 'b, Message> {
560    entries: &'a [MenuEntry<'b, Message>],
561    state: &'a mut MenuState,
562    theme: Theme,
563    content: MenuContentProps,
564    overlay: MenuOverlayProps<Message>,
565    viewport: Rectangle,
566    font: Font,
567    anchor_position: Point,
568    target_size: Size,
569}
570
571impl<Message> iced::advanced::Overlay<Message, iced::Theme, iced::Renderer>
572    for MenuOverlay<'_, '_, Message>
573where
574    Message: Clone,
575{
576    fn layout(&mut self, renderer: &iced::Renderer, bounds: Size) -> layout::Node {
577        let metrics = menu_metrics(&self.theme, self.content.size);
578        let menu_width = self
579            .overlay
580            .width
581            .map(|w| w as f32)
582            .unwrap_or(self.target_size.width.max(128.0));
583        let overlay_bounds = Size::new(
584            bounds.width.max(self.viewport.width),
585            bounds.height.max(self.viewport.height),
586        );
587
588        let limits = layout::Limits::new(Size::ZERO, overlay_bounds).width(menu_width);
589
590        let mut main_list = MenuList {
591            entries: self.entries,
592            hovered_row: &mut self.state.hovered_row,
593            open_submenu: Some(&mut self.state.open_submenu),
594            is_open: Some(&mut self.state.is_open),
595            metrics,
596            font: self.font,
597            content: self.content,
598            theme: self.theme.clone(),
599        };
600
601        self.state
602            .overlay
603            .main_tree
604            .diff::<Message, iced::Theme, iced::Renderer>(&main_list as &dyn Widget<_, _, _>);
605
606        let main_node = main_list.layout(&mut self.state.overlay.main_tree, renderer, &limits);
607        let main_size = main_node.size();
608
609        let collision_padding = 10.0;
610        let min_x = self.viewport.x + collision_padding;
611        let max_x = (self.viewport.x + self.viewport.width - main_size.width - collision_padding)
612            .max(min_x);
613        let min_y = self.viewport.y + collision_padding;
614        let max_y = (self.viewport.y + self.viewport.height - main_size.height - collision_padding)
615            .max(min_y);
616        let space_below = bounds.height - (self.anchor_position.y + self.target_size.height);
617        let space_above = self.anchor_position.y;
618
619        let x = match self.overlay.kind {
620            MenuKind::Dropdown => self.anchor_position.x,
621            MenuKind::Context => self.state.opened_at.unwrap_or(self.anchor_position).x,
622        };
623
624        let x = x.clamp(min_x, max_x);
625
626        let y = match self.overlay.kind {
627            MenuKind::Dropdown => {
628                if space_below >= space_above {
629                    self.anchor_position.y + self.target_size.height + self.overlay.offset
630                } else {
631                    self.anchor_position.y - main_size.height - self.overlay.offset
632                }
633            }
634            MenuKind::Context => self.state.opened_at.unwrap_or(self.anchor_position).y,
635        }
636        .clamp(min_y, max_y);
637
638        let mut children = Vec::new();
639        let main_node = main_node.move_to(Point::new(x, y));
640        self.state.overlay_bounds = Some(main_node.bounds());
641        children.push(main_node);
642
643        if let Some(submenu_index) = self.state.open_submenu {
644            if let Some(MenuEntry::SubMenu(submenu)) = self.entries.get(submenu_index) {
645                let mut submenu_list = MenuList {
646                    entries: &submenu.entries,
647                    hovered_row: &mut self.state.hovered_sub_row,
648                    open_submenu: None,
649                    is_open: Some(&mut self.state.is_open),
650                    metrics,
651                    font: self.font,
652                    content: self.content,
653                    theme: self.theme.clone(),
654                };
655
656                self.state
657                    .overlay
658                    .submenu_tree
659                    .diff::<Message, iced::Theme, iced::Renderer>(
660                        &submenu_list as &dyn Widget<_, _, _>,
661                    );
662
663                let submenu_node =
664                    submenu_list.layout(&mut self.state.overlay.submenu_tree, renderer, &limits);
665
666                let submenu_size = submenu_node.size();
667                let submenu_gap = 4.0;
668                let right_x = x + main_size.width + submenu_gap;
669                let left_x = x - submenu_size.width - submenu_gap;
670                let submenu_min_x = self.viewport.x + collision_padding;
671                let submenu_max_x = (self.viewport.x + self.viewport.width
672                    - submenu_size.width
673                    - collision_padding)
674                    .max(submenu_min_x);
675                let submenu_x = if right_x <= submenu_max_x {
676                    right_x
677                } else {
678                    left_x.clamp(submenu_min_x, submenu_max_x)
679                };
680                let submenu_min_y = self.viewport.y + collision_padding;
681                let submenu_max_y = (self.viewport.y + self.viewport.height
682                    - submenu_size.height
683                    - collision_padding)
684                    .max(submenu_min_y);
685                let submenu_y = y.clamp(submenu_min_y, submenu_max_y);
686
687                let submenu_node = submenu_node.move_to(Point::new(submenu_x, submenu_y));
688                self.state.submenu_bounds = Some(submenu_node.bounds());
689                children.push(submenu_node);
690            } else {
691                self.state.open_submenu = None;
692                self.state.submenu_bounds = None;
693            }
694        } else {
695            self.state.submenu_bounds = None;
696        }
697
698        layout::Node::with_children(overlay_bounds, children)
699    }
700
701    fn update(
702        &mut self,
703        event: &Event,
704        layout: Layout<'_>,
705        cursor: mouse::Cursor,
706        renderer: &iced::Renderer,
707        clipboard: &mut dyn Clipboard,
708        shell: &mut Shell<'_, Message>,
709    ) {
710        let metrics = menu_metrics(&self.theme, self.content.size);
711        let bounds = layout.bounds();
712
713        let mut children = layout.children();
714        let Some(main_layout) = children.next() else {
715            return;
716        };
717
718        let mut main_list = MenuList {
719            entries: self.entries,
720            hovered_row: &mut self.state.hovered_row,
721            open_submenu: Some(&mut self.state.open_submenu),
722            is_open: Some(&mut self.state.is_open),
723            metrics,
724            font: self.font,
725            content: self.content,
726            theme: self.theme.clone(),
727        };
728
729        main_list.update(
730            &mut self.state.overlay.main_tree,
731            event,
732            main_layout,
733            cursor,
734            renderer,
735            clipboard,
736            shell,
737            &bounds,
738        );
739
740        if let Some(submenu_layout) = children.next()
741            && let Some(submenu_index) = self.state.open_submenu
742            && let Some(MenuEntry::SubMenu(submenu)) = self.entries.get(submenu_index)
743        {
744            let mut submenu_list = MenuList {
745                entries: &submenu.entries,
746                hovered_row: &mut self.state.hovered_sub_row,
747                open_submenu: None,
748                is_open: Some(&mut self.state.is_open),
749                metrics,
750                font: self.font,
751                content: self.content,
752                theme: self.theme.clone(),
753            };
754
755            submenu_list.update(
756                &mut self.state.overlay.submenu_tree,
757                event,
758                submenu_layout,
759                cursor,
760                renderer,
761                clipboard,
762                shell,
763                &bounds,
764            );
765        }
766    }
767
768    fn mouse_interaction(
769        &self,
770        layout: Layout<'_>,
771        cursor: mouse::Cursor,
772        _renderer: &iced::Renderer,
773    ) -> mouse::Interaction {
774        if cursor.is_over(layout.bounds()) {
775            mouse::Interaction::Pointer
776        } else {
777            mouse::Interaction::default()
778        }
779    }
780
781    fn draw(
782        &self,
783        renderer: &mut iced::Renderer,
784        _theme: &iced::Theme,
785        style: &renderer::Style,
786        layout: Layout<'_>,
787        cursor: mouse::Cursor,
788    ) {
789        let overlay_viewport = layout.bounds();
790        let metrics = menu_metrics(&self.theme, self.content.size);
791
792        for (index, child_layout) in layout.children().enumerate() {
793            let bounds = child_layout.bounds();
794            let menu_style = menu_style(&self.theme, self.content);
795
796            renderer.fill_quad(
797                renderer::Quad {
798                    bounds,
799                    border: menu_style.border(metrics.radius),
800                    shadow: menu_style.shadow,
801                    ..renderer::Quad::default()
802                },
803                menu_style.background,
804            );
805
806            if index == 0 {
807                let mut hovered_row = self.state.hovered_row;
808                let list = MenuList {
809                    entries: self.entries,
810                    hovered_row: &mut hovered_row,
811                    open_submenu: None,
812                    is_open: None,
813                    metrics,
814                    font: self.font,
815                    content: self.content,
816                    theme: self.theme.clone(),
817                };
818
819                <MenuList<'_, '_, Message> as Widget<Message, iced::Theme, iced::Renderer>>::draw(
820                    &list,
821                    &self.state.overlay.main_tree,
822                    renderer,
823                    _theme,
824                    style,
825                    child_layout,
826                    cursor,
827                    &overlay_viewport,
828                );
829            } else if let Some(submenu_index) = self.state.open_submenu
830                && let Some(MenuEntry::SubMenu(submenu)) = self.entries.get(submenu_index)
831            {
832                let mut hovered_row = self.state.hovered_sub_row;
833                let list = MenuList {
834                    entries: &submenu.entries,
835                    hovered_row: &mut hovered_row,
836                    open_submenu: None,
837                    is_open: None,
838                    metrics,
839                    font: self.font,
840                    content: self.content,
841                    theme: self.theme.clone(),
842                };
843
844                <MenuList<'_, '_, Message> as Widget<Message, iced::Theme, iced::Renderer>>::draw(
845                    &list,
846                    &self.state.overlay.submenu_tree,
847                    renderer,
848                    _theme,
849                    style,
850                    child_layout,
851                    cursor,
852                    &overlay_viewport,
853                );
854            }
855        }
856    }
857}
858
859#[derive(Clone, Copy, Debug)]
860struct MenuMetrics {
861    content_padding: f32,
862    item_height: f32,
863    label_height: f32,
864    separator_height: f32,
865    font_size: f32,
866    label_font_size: f32,
867    shortcut_font_size: f32,
868    indicator_size: f32,
869    base_padding_x: f32,
870    inset_padding_x: f32,
871    radius: f32,
872}
873
874fn menu_metrics(theme: &Theme, size: MenuContentSize) -> MenuMetrics {
875    match size {
876        MenuContentSize::Size1 => MenuMetrics {
877            content_padding: theme.spacing.xs,
878            item_height: 28.0,
879            label_height: 28.0,
880            separator_height: 9.0,
881            font_size: 12.0,
882            label_font_size: 12.0,
883            shortcut_font_size: 10.0,
884            indicator_size: 12.0,
885            base_padding_x: theme.spacing.sm,
886            inset_padding_x: 20.0,
887            radius: theme.radius.md,
888        },
889        MenuContentSize::Size2 => MenuMetrics {
890            content_padding: theme.spacing.xs,
891            item_height: 32.0,
892            label_height: 32.0,
893            separator_height: 9.0,
894            font_size: 14.0,
895            label_font_size: 14.0,
896            shortcut_font_size: 12.0,
897            indicator_size: 14.0,
898            base_padding_x: theme.spacing.sm,
899            inset_padding_x: 24.0,
900            radius: theme.radius.md,
901        },
902    }
903}
904
905#[derive(Clone, Copy)]
906struct ResolvedMenuStyle {
907    background: Background,
908    border_color: Color,
909    shadow: Shadow,
910    text_color: Color,
911    muted_text_color: Color,
912    disabled_text_color: Color,
913}
914
915impl ResolvedMenuStyle {
916    fn border(&self, radius: f32) -> Border {
917        Border {
918            color: self.border_color,
919            width: crate::theme::ThemeStyles::default().menu.border_width,
920            radius: radius.into(),
921        }
922    }
923}
924
925fn apply_opacity(mut color: Color, opacity: f32) -> Color {
926    color.a *= opacity;
927    color
928}
929
930fn menu_style(theme: &Theme, props: MenuContentProps) -> ResolvedMenuStyle {
931    let shadow = if props.show_shadow {
932        Shadow {
933            color: Color {
934                a: theme.styles.menu.shadow.opacity,
935                ..theme.palette.foreground
936            },
937            offset: Vector::new(0.0, theme.styles.menu.shadow.offset_y),
938            blur_radius: theme.styles.menu.shadow.blur_radius,
939        }
940    } else {
941        Shadow::default()
942    };
943
944    ResolvedMenuStyle {
945        background: Background::Color(theme.palette.popover),
946        border_color: theme.palette.border,
947        shadow,
948        text_color: theme.palette.popover_foreground,
949        muted_text_color: theme.palette.muted_foreground,
950        disabled_text_color: apply_opacity(theme.palette.popover_foreground, 0.45),
951    }
952}
953
954fn hovered_colors(
955    theme: &Theme,
956    content: MenuContentProps,
957    item_color: AccentColor,
958) -> (Background, Color) {
959    let is_gray = item_color == AccentColor::Gray;
960    match content.variant {
961        MenuContentVariant::Solid => {
962            if content.high_contrast {
963                let bg = if is_gray {
964                    theme.palette.foreground
965                } else {
966                    accent_high(&theme.palette, item_color)
967                };
968                let fg = if is_gray {
969                    theme.palette.background
970                } else {
971                    accent_foreground(&theme.palette, item_color)
972                };
973                (Background::Color(bg), fg)
974            } else {
975                let bg = if is_gray {
976                    theme.palette.accent
977                } else {
978                    accent_color(&theme.palette, item_color)
979                };
980                let fg = if is_gray {
981                    theme.palette.accent_foreground
982                } else {
983                    accent_foreground(&theme.palette, item_color)
984                };
985                (Background::Color(bg), fg)
986            }
987        }
988        MenuContentVariant::Soft => {
989            let bg = if is_gray {
990                theme.palette.accent
991            } else {
992                accent_soft(&theme.palette, item_color)
993            };
994            let fg = if is_gray {
995                theme.palette.accent_foreground
996            } else {
997                accent_soft_foreground(&theme.palette, item_color)
998            };
999            (Background::Color(bg), fg)
1000        }
1001    }
1002}
1003
1004#[derive(Debug, Default)]
1005struct MenuListState {
1006    is_hovered: Option<bool>,
1007}
1008
1009struct MenuRow<'a, Message> {
1010    height: f32,
1011    kind: MenuRowKind<'a, Message>,
1012}
1013
1014#[derive(Clone, Copy, Debug)]
1015enum MenuIndicator {
1016    Check,
1017    Radio,
1018}
1019
1020enum MenuRowKind<'a, Message> {
1021    Label(Cow<'a, str>),
1022    Separator,
1023    Item {
1024        entry_index: usize,
1025        label: Cow<'a, str>,
1026        disabled: bool,
1027        inset: bool,
1028        shortcut: Option<Cow<'a, str>>,
1029        indicator: Option<MenuIndicator>,
1030        submenu: bool,
1031        on_select: Option<Message>,
1032        color: Option<AccentColor>,
1033    },
1034}
1035
1036fn build_rows<'a, Message: Clone>(
1037    entries: &'a [MenuEntry<'a, Message>],
1038    metrics: MenuMetrics,
1039) -> Vec<MenuRow<'a, Message>> {
1040    let mut rows = Vec::new();
1041    for (index, entry) in entries.iter().enumerate() {
1042        match entry {
1043            MenuEntry::Label(text) => rows.push(MenuRow {
1044                height: metrics.label_height,
1045                kind: MenuRowKind::Label(text.clone()),
1046            }),
1047            MenuEntry::Separator => rows.push(MenuRow {
1048                height: metrics.separator_height,
1049                kind: MenuRowKind::Separator,
1050            }),
1051            MenuEntry::Item(item) => rows.push(MenuRow {
1052                height: metrics.item_height,
1053                kind: MenuRowKind::Item {
1054                    entry_index: index,
1055                    label: item.label.clone(),
1056                    disabled: item.props.disabled,
1057                    inset: item.props.inset,
1058                    shortcut: item.props.shortcut.clone(),
1059                    indicator: None,
1060                    submenu: false,
1061                    on_select: item.on_select.clone(),
1062                    color: item.props.color,
1063                },
1064            }),
1065            MenuEntry::CheckboxItem(item) => rows.push(MenuRow {
1066                height: metrics.item_height,
1067                kind: MenuRowKind::Item {
1068                    entry_index: index,
1069                    label: item.label.clone(),
1070                    disabled: item.props.disabled,
1071                    inset: item.props.inset,
1072                    shortcut: item.props.shortcut.clone(),
1073                    indicator: item.checked.then_some(MenuIndicator::Check),
1074                    submenu: false,
1075                    on_select: item.on_toggle.clone(),
1076                    color: item.props.color,
1077                },
1078            }),
1079            MenuEntry::RadioItem(item) => rows.push(MenuRow {
1080                height: metrics.item_height,
1081                kind: MenuRowKind::Item {
1082                    entry_index: index,
1083                    label: item.label.clone(),
1084                    disabled: item.props.disabled,
1085                    inset: item.props.inset,
1086                    shortcut: item.props.shortcut.clone(),
1087                    indicator: item.selected.then_some(MenuIndicator::Radio),
1088                    submenu: false,
1089                    on_select: item.on_select.clone(),
1090                    color: item.props.color,
1091                },
1092            }),
1093            MenuEntry::SubMenu(item) => rows.push(MenuRow {
1094                height: metrics.item_height,
1095                kind: MenuRowKind::Item {
1096                    entry_index: index,
1097                    label: item.label.clone(),
1098                    disabled: item.props.disabled,
1099                    inset: item.props.inset,
1100                    shortcut: item.props.shortcut.clone(),
1101                    indicator: None,
1102                    submenu: true,
1103                    on_select: None,
1104                    color: item.props.color,
1105                },
1106            }),
1107        }
1108    }
1109    rows
1110}
1111
1112struct MenuList<'a, 'b, Message> {
1113    entries: &'a [MenuEntry<'b, Message>],
1114    hovered_row: &'a mut Option<usize>,
1115    open_submenu: Option<&'a mut Option<usize>>,
1116    is_open: Option<&'a mut bool>,
1117    metrics: MenuMetrics,
1118    font: Font,
1119    content: MenuContentProps,
1120    theme: Theme,
1121}
1122
1123impl<Message> Widget<Message, iced::Theme, iced::Renderer> for MenuList<'_, '_, Message>
1124where
1125    Message: Clone,
1126{
1127    fn tag(&self) -> iced::advanced::widget::tree::Tag {
1128        iced::advanced::widget::tree::Tag::of::<MenuListState>()
1129    }
1130
1131    fn state(&self) -> iced::advanced::widget::tree::State {
1132        iced::advanced::widget::tree::State::new(MenuListState::default())
1133    }
1134
1135    fn size(&self) -> Size<Length> {
1136        Size::new(Length::Fill, Length::Shrink)
1137    }
1138
1139    fn layout(
1140        &mut self,
1141        _tree: &mut Tree,
1142        _renderer: &iced::Renderer,
1143        limits: &layout::Limits,
1144    ) -> layout::Node {
1145        let rows = build_rows(self.entries, self.metrics);
1146        let content_height = rows.iter().map(|row| row.height).sum::<f32>();
1147        let intrinsic = Size::new(0.0, content_height + self.metrics.content_padding * 2.0);
1148        layout::Node::new(limits.resolve(Length::Fill, Length::Shrink, intrinsic))
1149    }
1150
1151    fn update(
1152        &mut self,
1153        tree: &mut Tree,
1154        event: &Event,
1155        layout: Layout<'_>,
1156        cursor: mouse::Cursor,
1157        _renderer: &iced::Renderer,
1158        _clipboard: &mut dyn Clipboard,
1159        shell: &mut Shell<'_, Message>,
1160        _viewport: &Rectangle,
1161    ) {
1162        let bounds = layout.bounds();
1163        let rows = build_rows(self.entries, self.metrics);
1164        let state = tree.state.downcast_mut::<MenuListState>();
1165
1166        let list_bounds = Rectangle {
1167            x: bounds.x,
1168            y: bounds.y,
1169            width: bounds.width,
1170            height: bounds.height,
1171        };
1172
1173        fn row_at(rows: &[MenuRow<'_, impl Clone>], y: f32) -> Option<usize> {
1174            let mut cursor = 0.0;
1175            for (idx, row) in rows.iter().enumerate() {
1176                let next = cursor + row.height;
1177                if y >= cursor && y < next {
1178                    return Some(idx);
1179                }
1180                cursor = next;
1181            }
1182            None
1183        }
1184
1185        match event {
1186            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
1187                if let Some(pos) = cursor.position_in(list_bounds) {
1188                    let y = (pos.y - self.metrics.content_padding).max(0.0);
1189                    if let Some(index) = row_at(&rows, y) {
1190                        match &rows[index].kind {
1191                            MenuRowKind::Item { disabled: true, .. } => *self.hovered_row = None,
1192                            MenuRowKind::Item { .. } => *self.hovered_row = Some(index),
1193                            _ => *self.hovered_row = None,
1194                        }
1195                    } else {
1196                        *self.hovered_row = None;
1197                    }
1198                } else {
1199                    *self.hovered_row = None;
1200                }
1201            }
1202            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
1203            | Event::Touch(touch::Event::FingerPressed { .. }) => {
1204                if let Some(pos) = cursor.position_in(list_bounds) {
1205                    let y = (pos.y - self.metrics.content_padding).max(0.0);
1206                    if let Some(index) = row_at(&rows, y)
1207                        && let MenuRowKind::Item {
1208                            entry_index,
1209                            disabled: false,
1210                            submenu,
1211                            on_select,
1212                            ..
1213                        } = &rows[index].kind
1214                    {
1215                        if *submenu {
1216                            if let Some(open_submenu) = self.open_submenu.as_deref_mut() {
1217                                if open_submenu.as_ref() == Some(entry_index) {
1218                                    *open_submenu = None;
1219                                } else {
1220                                    *open_submenu = Some(*entry_index);
1221                                }
1222                            }
1223                        } else {
1224                            if let Some(is_open) = self.is_open.as_deref_mut() {
1225                                *is_open = false;
1226                            }
1227                            if let Some(open_submenu) = self.open_submenu.as_deref_mut() {
1228                                *open_submenu = None;
1229                            }
1230                            if let Some(message) = on_select.clone() {
1231                                shell.publish(message);
1232                            }
1233                        }
1234                        shell.capture_event();
1235                    }
1236                }
1237            }
1238            _ => {}
1239        }
1240
1241        if let Event::Window(iced::window::Event::RedrawRequested(_now)) = event {
1242            state.is_hovered = Some(cursor.is_over(bounds));
1243        } else if state
1244            .is_hovered
1245            .is_some_and(|is_hovered| is_hovered != cursor.is_over(bounds))
1246        {
1247            shell.request_redraw();
1248        }
1249    }
1250
1251    fn mouse_interaction(
1252        &self,
1253        _tree: &Tree,
1254        layout: Layout<'_>,
1255        cursor: mouse::Cursor,
1256        _viewport: &Rectangle,
1257        _renderer: &iced::Renderer,
1258    ) -> mouse::Interaction {
1259        if cursor.is_over(layout.bounds()) {
1260            mouse::Interaction::Pointer
1261        } else {
1262            mouse::Interaction::default()
1263        }
1264    }
1265
1266    fn draw(
1267        &self,
1268        _tree: &Tree,
1269        renderer: &mut iced::Renderer,
1270        _theme: &iced::Theme,
1271        _style: &renderer::Style,
1272        layout: Layout<'_>,
1273        _cursor: mouse::Cursor,
1274        viewport: &Rectangle,
1275    ) {
1276        let bounds = layout.bounds();
1277        if !bounds.intersects(viewport) {
1278            return;
1279        }
1280
1281        let rows = build_rows(self.entries, self.metrics);
1282        let menu_style = menu_style(&self.theme, self.content);
1283
1284        let mut y = bounds.y + self.metrics.content_padding;
1285        for (index, row) in rows.iter().enumerate() {
1286            let row_bounds = Rectangle {
1287                x: bounds.x + self.metrics.content_padding,
1288                y,
1289                width: (bounds.width - self.metrics.content_padding * 2.0).max(0.0),
1290                height: row.height,
1291            };
1292            y += row.height;
1293
1294            match &row.kind {
1295                MenuRowKind::Separator => {
1296                    let line_y = row_bounds.y + row_bounds.height / 2.0;
1297                    renderer.fill_quad(
1298                        renderer::Quad {
1299                            bounds: Rectangle {
1300                                x: bounds.x,
1301                                y: line_y,
1302                                width: bounds.width,
1303                                height: 1.0,
1304                            },
1305                            ..renderer::Quad::default()
1306                        },
1307                        Background::Color(menu_style.border_color),
1308                    );
1309                }
1310                MenuRowKind::Label(label) => {
1311                    let label_font = Font {
1312                        weight: Weight::Medium,
1313                        ..self.font
1314                    };
1315                    let label_x =
1316                        row_bounds.x + self.metrics.base_padding_x + self.metrics.inset_padding_x;
1317                    renderer.fill_text(
1318                        text::Text {
1319                            content: label.to_string(),
1320                            size: self.metrics.label_font_size.into(),
1321                            line_height: text::LineHeight::Absolute(
1322                                self.metrics.label_height.into(),
1323                            ),
1324                            font: label_font,
1325                            bounds: Size::new(row_bounds.width, row_bounds.height),
1326                            align_x: text::Alignment::Left,
1327                            align_y: iced::alignment::Vertical::Center,
1328                            shaping: text::Shaping::Basic,
1329                            wrapping: text::Wrapping::default(),
1330                        },
1331                        Point::new(label_x, row_bounds.center_y()),
1332                        menu_style.text_color,
1333                        *viewport,
1334                    );
1335                }
1336                MenuRowKind::Item {
1337                    label,
1338                    disabled,
1339                    inset,
1340                    shortcut,
1341                    indicator,
1342                    submenu,
1343                    color,
1344                    ..
1345                } => {
1346                    let is_hovered = self.hovered_row.is_some_and(|hovered| hovered == index);
1347                    let item_color = color.unwrap_or(self.content.color);
1348                    let icon_font = Font::with_name("lucide");
1349
1350                    let mut text_color = menu_style.text_color;
1351                    if is_hovered && !disabled {
1352                        let (bg, fg) = hovered_colors(&self.theme, self.content, item_color);
1353                        text_color = fg;
1354                        renderer.fill_quad(
1355                            renderer::Quad {
1356                                bounds: row_bounds,
1357                                border: Border {
1358                                    radius: self.metrics.radius.into(),
1359                                    ..Border::default()
1360                                },
1361                                ..renderer::Quad::default()
1362                            },
1363                            bg,
1364                        );
1365                    }
1366
1367                    if *disabled {
1368                        text_color = menu_style.disabled_text_color;
1369                    }
1370
1371                    let needs_inset = *inset || indicator.is_some();
1372                    let label_x = row_bounds.x
1373                        + self.metrics.base_padding_x
1374                        + if needs_inset {
1375                            self.metrics.inset_padding_x
1376                        } else {
1377                            0.0
1378                        };
1379
1380                    if let Some(indicator) = indicator {
1381                        let (icon, icon_size) = match indicator {
1382                            MenuIndicator::Check => {
1383                                (LucideIcon::Check, self.metrics.indicator_size)
1384                            }
1385                            MenuIndicator::Radio => (
1386                                LucideIcon::Circle,
1387                                (self.metrics.indicator_size * 0.6).max(8.0),
1388                            ),
1389                        };
1390                        renderer.fill_text(
1391                            text::Text {
1392                                content: char::from(icon).to_string(),
1393                                size: icon_size.into(),
1394                                line_height: text::LineHeight::Absolute(icon_size.into()),
1395                                font: icon_font,
1396                                bounds: Size::new(icon_size, row_bounds.height),
1397                                align_x: text::Alignment::Center,
1398                                align_y: iced::alignment::Vertical::Center,
1399                                shaping: text::Shaping::Basic,
1400                                wrapping: text::Wrapping::default(),
1401                            },
1402                            Point::new(
1403                                row_bounds.x + self.metrics.base_padding_x,
1404                                row_bounds.center_y(),
1405                            ),
1406                            text_color,
1407                            *viewport,
1408                        );
1409                    }
1410
1411                    renderer.fill_text(
1412                        text::Text {
1413                            content: label.to_string(),
1414                            size: self.metrics.font_size.into(),
1415                            line_height: text::LineHeight::Absolute(row_bounds.height.into()),
1416                            font: self.font,
1417                            bounds: Size::new(row_bounds.width, row_bounds.height),
1418                            align_x: text::Alignment::Left,
1419                            align_y: iced::alignment::Vertical::Center,
1420                            shaping: text::Shaping::Basic,
1421                            wrapping: text::Wrapping::default(),
1422                        },
1423                        Point::new(label_x, row_bounds.center_y()),
1424                        text_color,
1425                        *viewport,
1426                    );
1427
1428                    if let Some(shortcut) = shortcut {
1429                        let shortcut_color = if *disabled {
1430                            menu_style.disabled_text_color
1431                        } else {
1432                            menu_style.muted_text_color
1433                        };
1434                        let shortcut_bounds = Size::new(
1435                            (row_bounds.width - self.metrics.base_padding_x * 2.0).max(0.0),
1436                            row_bounds.height,
1437                        );
1438                        renderer.fill_text(
1439                            text::Text {
1440                                content: shortcut.to_string(),
1441                                size: self.metrics.shortcut_font_size.into(),
1442                                line_height: text::LineHeight::Absolute(row_bounds.height.into()),
1443                                font: self.font,
1444                                bounds: shortcut_bounds,
1445                                align_x: text::Alignment::Right,
1446                                align_y: iced::alignment::Vertical::Center,
1447                                shaping: text::Shaping::Basic,
1448                                wrapping: text::Wrapping::default(),
1449                            },
1450                            Point::new(
1451                                row_bounds.x + self.metrics.base_padding_x,
1452                                row_bounds.center_y(),
1453                            ),
1454                            shortcut_color,
1455                            *viewport,
1456                        );
1457                    }
1458
1459                    if *submenu {
1460                        let icon_size = self.metrics.indicator_size;
1461                        let submenu_bounds = Size::new(
1462                            (row_bounds.width - self.metrics.base_padding_x * 2.0).max(0.0),
1463                            row_bounds.height,
1464                        );
1465                        renderer.fill_text(
1466                            text::Text {
1467                                content: char::from(LucideIcon::ChevronRight).to_string(),
1468                                size: icon_size.into(),
1469                                line_height: text::LineHeight::Absolute(icon_size.into()),
1470                                font: icon_font,
1471                                bounds: submenu_bounds,
1472                                align_x: text::Alignment::Right,
1473                                align_y: iced::alignment::Vertical::Center,
1474                                shaping: text::Shaping::Basic,
1475                                wrapping: text::Wrapping::default(),
1476                            },
1477                            Point::new(
1478                                row_bounds.x + self.metrics.base_padding_x,
1479                                row_bounds.center_y(),
1480                            ),
1481                            menu_style.muted_text_color,
1482                            *viewport,
1483                        );
1484                    }
1485                }
1486            }
1487        }
1488    }
1489}