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}