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}