Skip to main content

snora_widgets/
menu.rs

1//! Render a [`snora_core::Menu`] into an `iced::Element`.
2//!
3//! A menu renders as a column: the trigger button on top, and — if the
4//! menu is currently active — the item list below. The engine installs
5//! the click-outside backdrop at the application level, so this widget
6//! only paints the menu's own surface.
7
8use std::fmt::Debug;
9
10use iced::{
11    Alignment::Center,
12    Element,
13    widget::{button, column, container, row, text},
14};
15
16use snora_core::{Menu, MenuAction};
17
18use crate::icon::icon_element;
19use crate::style::menu_button_style;
20
21/// Geometry parameter [`build_menu`] takes, letting [`render_menu`]
22/// (unstyled) and the `design`-gated styled variant (RFC-040) share one
23/// implementation.
24pub(crate) struct MenuGeometry {
25    /// Gap between a menu/item's icon and its label.
26    pub(crate) gap: f32,
27}
28
29impl MenuGeometry {
30    /// Today's literal, unmodified.
31    pub(crate) const fn unstyled() -> Self {
32        Self { gap: 6.0 }
33    }
34}
35
36/// Render a single menu (header button + dropdown when active).
37///
38/// `on_action` is called for every interaction and must map the resulting
39/// [`MenuAction`] into the application's message type. A plain function
40/// item works well here because it has a zero-sized `'static` type and
41/// can be passed by reference.
42pub fn render_menu<'a, Message, MenuId, MenuItemId, F>(
43    menu: Menu<MenuId, MenuItemId>,
44    on_action: &'a F,
45    is_active: bool,
46) -> Element<'a, Message>
47where
48    Message: Clone + 'a,
49    MenuId: Clone + Debug + PartialEq + 'a,
50    MenuItemId: Clone + Debug + 'a,
51    F: Fn(MenuAction<MenuId, MenuItemId>) -> Message + 'a,
52{
53    build_menu(menu, on_action, is_active, MenuGeometry::unstyled())
54}
55
56pub(crate) fn build_menu<'a, Message, MenuId, MenuItemId, F>(
57    menu: Menu<MenuId, MenuItemId>,
58    on_action: &'a F,
59    is_active: bool,
60    geometry: MenuGeometry,
61) -> Element<'a, Message>
62where
63    Message: Clone + 'a,
64    MenuId: Clone + Debug + PartialEq + 'a,
65    MenuItemId: Clone + Debug + 'a,
66    F: Fn(MenuAction<MenuId, MenuItemId>) -> Message + 'a,
67{
68    // Trigger button (always visible).
69    let mut header_content = row![].spacing(geometry.gap).align_y(Center);
70    if let Some(ref ic) = menu.icon {
71        header_content = header_content.push(icon_element(ic));
72    }
73    header_content = header_content.push(text(menu.label).size(14));
74
75    let trigger_msg = on_action(MenuAction::MenuPressed(menu.id.clone()));
76
77    let mut stack = column![];
78    stack = stack.push(
79        button(header_content)
80            .style(menu_button_style)
81            .on_press(trigger_msg),
82    );
83
84    if !is_active {
85        return container(stack).into();
86    }
87
88    // Dropdown — only rendered when this menu is active.
89    for item in menu.items {
90        let mut btn_content = row![].spacing(geometry.gap).align_y(Center);
91        if let Some(ref ic) = item.icon {
92            btn_content = btn_content.push(icon_element(ic));
93        }
94        btn_content = btn_content.push(text(item.label).size(14));
95
96        let msg = on_action(MenuAction::MenuItemPressed {
97            menu_id: item.menu_id.clone(),
98            menu_item_id: item.id.clone(),
99        });
100
101        stack = stack.push(button(btn_content).style(menu_button_style).on_press(msg));
102    }
103
104    container(stack).into()
105}