Skip to main content

iced_m3/widget/
vertical_menu.rs

1use std::sync::LazyLock;
2
3use iced::{Alignment, Border, Color, Element, Font, Length, Pixels, border::Radius, padding};
4use iced_widget::{button, column, container, row, rule, space, text};
5
6use crate::{
7    DIM_ALPHA, HOVER_STATE_LAYER_OPACITY, PRESSED_STATE_LAYER_OPACITY,
8    style::{Elevation, shadow},
9    theme::ColorScheme,
10    widget::drop_down_menu,
11};
12
13pub enum Action<'a, Message> {
14    Menu(Vec<Group<'a, Message>>),
15    Message(Option<Message>),
16}
17
18pub enum Entry<'a, Message> {
19    Button {
20        icon: Option<&'a char>,
21        label: &'a str,
22        supporting_text: Option<&'a str>,
23        error: bool,
24        action: Action<'a, Message>,
25    },
26    Separator,
27}
28
29pub struct Group<'a, Message> {
30    pub label: Option<&'a str>,
31    pub entries: Vec<Entry<'a, Message>>,
32}
33
34impl<'a, Message> Group<'a, Message> {
35    pub fn new(entries: Vec<Entry<'a, Message>>) -> Self {
36        Self {
37            label: None,
38            entries,
39        }
40    }
41}
42
43pub struct Menu<'a, Message> {
44    groups: Vec<Group<'a, Message>>,
45    vibrant: bool,
46    font: Option<Font>,
47    icon_font: Option<Font>,
48    width: Option<f32>,
49    theme: &'a dyn ColorScheme,
50    trailing_icon: &'a char,
51}
52
53pub static ARROW_RIGHT: LazyLock<char> = LazyLock::new(|| char::from_u32(0xe5df).unwrap());
54
55impl<'a, Message> Menu<'a, Message> {
56    #[must_use]
57    pub fn new(groups: Vec<Group<'a, Message>>, theme: &'a dyn ColorScheme) -> Self {
58        Self {
59            groups,
60            vibrant: false,
61            font: None,
62            icon_font: None,
63            width: Some(192.0),
64            theme,
65            trailing_icon: &ARROW_RIGHT,
66        }
67    }
68
69    #[must_use]
70    pub fn vibrant(mut self, vibrant: bool) -> Self {
71        self.vibrant = vibrant;
72        self
73    }
74
75    #[must_use]
76    pub fn font(mut self, font: Font) -> Self {
77        self.font = Some(font);
78        self
79    }
80
81    #[must_use]
82    pub fn icon_font(mut self, icon_font: Font) -> Self {
83        self.icon_font = Some(icon_font);
84        self
85    }
86
87    #[must_use]
88    pub fn width(mut self, width: Option<f32>) -> Self {
89        self.width = width;
90        self
91    }
92}
93
94const SECTION_PADDING: f32 = 4.0;
95const SPACING: f32 = 2.0;
96const LABEL_HEIGHT: Length = Length::Fixed(32.0);
97const BUTTON_HEIGHT: Length = Length::Fixed(48.0);
98const LABEL_TEXT_SIZE: Pixels = Pixels(16.0);
99const SUPPORTING_TEXT_SIZE: Pixels = Pixels(12.0);
100const ICON_SIZE: Pixels = Pixels(20.0);
101static BUTTON_RADIUS: LazyLock<Radius> = LazyLock::new(|| Radius::new(12.0));
102
103impl<'a, Message, Theme, Renderer> From<Menu<'a, Message>> for Element<'a, Message, Theme, Renderer>
104where
105    Renderer: 'a + iced::advanced::Renderer + iced::advanced::text::Renderer,
106    <Renderer as iced::advanced::text::Renderer>::Font: From<iced::Font>,
107    Message: 'a + Clone,
108    Theme: 'a
109        + iced_widget::container::Catalog
110        + iced_widget::text::Catalog
111        + iced_widget::button::Catalog
112        + iced_widget::rule::Catalog,
113    <Theme as iced_widget::container::Catalog>::Class<'a>:
114        From<iced_widget::container::StyleFn<'a, Theme>>,
115    <Theme as iced_widget::text::Catalog>::Class<'a>: From<iced_widget::text::StyleFn<'a, Theme>>,
116    <Theme as iced_widget::button::Catalog>::Class<'a>:
117        From<iced_widget::button::StyleFn<'a, Theme>>,
118    <Theme as iced_widget::rule::Catalog>::Class<'a>: From<iced_widget::rule::StyleFn<'a, Theme>>,
119{
120    fn from(menu: Menu<'a, Message>) -> Self {
121        let vibrant = menu.vibrant;
122        let theme = menu.theme;
123        let font = menu.font.unwrap_or_default();
124        let icon_font = menu.icon_font.unwrap_or_default();
125        let shadow_color = menu.theme.shadow();
126        let trailing_icon = menu.trailing_icon;
127        let container_width = match menu.width {
128            Some(pixels) => Length::Fixed(pixels),
129            None => Length::Shrink,
130        };
131        let label_color = if menu.vibrant {
132            menu.theme.on_tertiary_container()
133        } else {
134            menu.theme.on_surface_variant()
135        };
136        let separator_color = if menu.vibrant {
137            menu.theme.on_tertiary_container().scale_alpha(0.3)
138        } else {
139            menu.theme.outline_variant()
140        };
141        let bg = if menu.vibrant {
142            menu.theme.tertiary_container()
143        } else {
144            menu.theme.surface_container_low()
145        };
146        let icon_color = if menu.vibrant {
147            menu.theme.on_tertiary_container()
148        } else {
149            menu.theme.on_surface_variant()
150        };
151        let button_label_color = if menu.vibrant {
152            menu.theme.on_tertiary_container()
153        } else {
154            menu.theme.on_surface()
155        };
156        let supporting_text_color = if menu.vibrant {
157            menu.theme.on_tertiary_container()
158        } else {
159            menu.theme.on_surface_variant()
160        };
161        let state_layer_color = if menu.vibrant {
162            menu.theme.on_tertiary_container()
163        } else {
164            menu.theme.on_surface()
165        };
166        let button_hover_color = state_layer_color.scale_alpha(HOVER_STATE_LAYER_OPACITY);
167        let button_pressed_color = state_layer_color.scale_alpha(PRESSED_STATE_LAYER_OPACITY);
168        let error_content_color = menu.theme.error();
169        let error_state_layer_color = menu.theme.on_error_container();
170        let button_error_hover_color =
171            error_state_layer_color.scale_alpha(HOVER_STATE_LAYER_OPACITY);
172        let button_error_press_color =
173            error_state_layer_color.scale_alpha(PRESSED_STATE_LAYER_OPACITY);
174
175        let children: Vec<Element<'_, Message, Theme, Renderer>> = menu
176            .groups
177            .into_iter()
178            .map(|group| -> Element<'_, Message, Theme, Renderer> {
179                let mut children: Vec<Element<'a, Message, Theme, Renderer>> = Vec::new();
180                if let Some(label) = group.label {
181                    children.push(
182                        container(
183                            text(label)
184                                .size(LABEL_TEXT_SIZE)
185                                .font(font)
186                                .height(Length::Fill)
187                                .center()
188                                .style(move |_| iced_widget::text::Style {
189                                    color: Some(label_color),
190                                }),
191                        )
192                        .padding(12.0)
193                        .height(LABEL_HEIGHT)
194                        .width(container_width)
195                        .into(),
196                    );
197                }
198                for entry in group.entries {
199                    match entry {
200                        Entry::Button {
201                            icon,
202                            label,
203                            supporting_text,
204                            error,
205                            action,
206                        } => {
207                            let button_disabled = match &action {
208                                Action::Menu(groups) => groups.is_empty(),
209                                Action::Message(message) => message.is_none(),
210                            };
211                            let content_alpha = if button_disabled { DIM_ALPHA } else { 1.0 };
212                            let trailing_icon_visible = matches!(action, Action::Menu(_));
213                            let content = move || -> Element<'a, Message, Theme, Renderer> {
214                                row![
215                                    icon.map(|i| text(i).size(ICON_SIZE).font(icon_font).style(
216                                        move |_| text::Style {
217                                            color: Some(if error {
218                                                error_content_color.scale_alpha(content_alpha)
219                                            } else {
220                                                icon_color.scale_alpha(content_alpha)
221                                            })
222                                        }
223                                    )),
224                                    column![
225                                        space().height(Length::Fill),
226                                        text(label).size(LABEL_TEXT_SIZE).font(font).style(
227                                            move |_: &Theme| text::Style {
228                                                color: Some(if error {
229                                                    error_content_color.scale_alpha(content_alpha)
230                                                } else {
231                                                    button_label_color.scale_alpha(content_alpha)
232                                                })
233                                            }
234                                        ),
235                                        supporting_text.map(
236                                            |t| -> iced::advanced::widget::Text<'_, _, Renderer> {
237                                                text(t).size(SUPPORTING_TEXT_SIZE).font(font).style(
238                                                    move |_| text::Style {
239                                                        color: Some(if error {
240                                                            error_content_color
241                                                                .scale_alpha(content_alpha)
242                                                        } else {
243                                                            supporting_text_color
244                                                                .scale_alpha(content_alpha)
245                                                        }),
246                                                    },
247                                                )
248                                            }
249                                        ),
250                                        space().height(Length::Fill),
251                                    ],
252                                    space().width(Length::Fill),
253                                    if trailing_icon_visible {
254                                        Some(
255                                            text(trailing_icon)
256                                                .size(ICON_SIZE)
257                                                .font(icon_font)
258                                                .style(move |_| text::Style {
259                                                    color: Some(if error {
260                                                        error_content_color
261                                                            .scale_alpha(content_alpha)
262                                                    } else {
263                                                        icon_color.scale_alpha(content_alpha)
264                                                    }),
265                                                }),
266                                        )
267                                    } else {
268                                        None
269                                    }
270                                ]
271                                .spacing(8.0)
272                                .align_y(Alignment::Center)
273                                .into()
274                            };
275                            match action {
276                                Action::Menu(groups) => children.push(
277                                    drop_down_menu(
278                                        move |_| container(content()).height(BUTTON_HEIGHT).into(),
279                                        if groups.is_empty() {
280                                            None
281                                        } else {
282                                            Some(Menu::new(groups, theme).vibrant(vibrant))
283                                        },
284                                        drop_down_menu::Placement::RightBottom,
285                                    )
286                                    .trigger_transparent(true)
287                                    .into(),
288                                ),
289                                Action::Message(message) => children.push(
290                                    button(content())
291                                        .style(move |_, status| button::Style {
292                                            background: Some(iced::Background::Color(
293                                                match status {
294                                                    button::Status::Active
295                                                    | button::Status::Disabled => {
296                                                        Color::TRANSPARENT
297                                                    }
298                                                    button::Status::Hovered => {
299                                                        if error {
300                                                            button_error_hover_color
301                                                        } else {
302                                                            button_hover_color
303                                                        }
304                                                    }
305                                                    button::Status::Pressed => {
306                                                        if error {
307                                                            button_error_press_color
308                                                        } else {
309                                                            button_pressed_color
310                                                        }
311                                                    }
312                                                },
313                                            )),
314                                            border: Border {
315                                                radius: *BUTTON_RADIUS,
316                                                ..Default::default()
317                                            },
318                                            ..Default::default()
319                                        })
320                                        .on_press_maybe(message)
321                                        .height(BUTTON_HEIGHT)
322                                        .into(),
323                                ),
324                            }
325                        }
326                        Entry::Separator => children.push(
327                            container(rule::horizontal(1).style(move |_| rule::Style {
328                                color: separator_color,
329                                radius: Radius::from(u32::MAX),
330                                fill_mode: rule::FillMode::Full,
331                                snap: true,
332                            }))
333                            .width(Length::Fill)
334                            .padding(padding::horizontal(8.0).vertical(2.0))
335                            .into(),
336                        ),
337                    }
338                }
339
340                container(column(children).spacing(2.0))
341                    .style(move |_: &Theme| container::Style {
342                        background: Some(iced::Background::Color(bg)),
343                        border: iced::Border {
344                            radius: Radius::new(16),
345                            ..Default::default()
346                        },
347                        shadow: shadow(shadow_color, Elevation::new(0.5)),
348                        ..Default::default()
349                    })
350                    .padding(SECTION_PADDING)
351                    .width(if let Length::Fixed(val) = container_width {
352                        Length::Fixed(val - 2.0 * SECTION_PADDING)
353                    } else {
354                        container_width
355                    })
356                    .into()
357            })
358            .collect();
359
360        column(children).spacing(SPACING).into()
361    }
362}