Skip to main content

freya_components/
menu.rs

1use freya_core::prelude::*;
2use torin::{
3    content::Content,
4    gaps::Gaps,
5    prelude::{
6        Alignment,
7        Area,
8        Position,
9        Size2D,
10    },
11    size::Size,
12};
13
14use crate::{
15    define_theme,
16    get_theme,
17};
18
19define_theme! {
20    for = MenuContainer; theme_field = theme;
21    for = Menu; theme_field = theme;
22    for = SubMenu; theme_field = theme;
23
24    %[component]
25    pub MenuContainer {
26        %[fields]
27        background: Color,
28        padding: Gaps,
29        shadow: Color,
30        border_fill: Color,
31        corner_radius: CornerRadius,
32    }
33}
34
35define_theme! {
36    for = MenuItem; theme_field = theme;
37    for = MenuButton; theme_field = theme;
38
39    %[component]
40    pub MenuItem {
41        %[fields]
42        background: Color,
43        hover_background: Color,
44        select_background: Color,
45        border_fill: Color,
46        focus_border_fill: Color,
47        corner_radius: CornerRadius,
48        color: Color,
49        select_color: Color,
50    }
51}
52
53/// Floating menu container.
54///
55/// # Example
56///
57/// ```rust
58/// # use freya::prelude::*;
59/// fn app() -> impl IntoElement {
60///     let mut show_menu = use_state(|| false);
61///
62///     rect()
63///         .child(
64///             Button::new()
65///                 .on_press(move |_| show_menu.toggle())
66///                 .child("Open Menu"),
67///         )
68///         .maybe_child(show_menu().then(|| {
69///             Menu::new()
70///                 .on_close(move |_| show_menu.set(false))
71///                 .child(MenuButton::new().child("Open"))
72///                 .child(MenuButton::new().child("Save"))
73///                 .child(
74///                     SubMenu::new()
75///                         .label("Export")
76///                         .child(MenuButton::new().child("PDF")),
77///                 )
78///         }))
79/// }
80/// ```
81///
82/// See the [interactive components demo](https://freyaui.dev/demo).
83#[derive(Default, Clone, PartialEq)]
84pub struct Menu {
85    pub(crate) theme: Option<MenuContainerThemePartial>,
86    children: Vec<Element>,
87    on_close: Option<EventHandler<()>>,
88    on_escape: Option<EventHandler<()>>,
89    key: DiffKey,
90}
91
92impl ChildrenExt for Menu {
93    fn children_mut(&mut self) -> &mut Vec<Element> {
94        &mut self.children
95    }
96}
97
98impl KeyExt for Menu {
99    fn key_mut(&mut self) -> &mut DiffKey {
100        &mut self.key
101    }
102}
103
104impl Menu {
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Called when Escape closes the menu, falls back to [`Menu::on_close`].
110    pub fn on_escape(mut self, f: impl Into<EventHandler<()>>) -> Self {
111        self.on_escape = Some(f.into());
112        self
113    }
114
115    pub fn on_close<F>(mut self, f: F) -> Self
116    where
117        F: Into<EventHandler<()>>,
118    {
119        self.on_close = Some(f.into());
120        self
121    }
122
123    pub fn theme(mut self, theme: MenuContainerThemePartial) -> Self {
124        self.theme = Some(theme);
125        self
126    }
127}
128
129impl ComponentOwned for Menu {
130    fn render(self) -> impl IntoElement {
131        // Provide the menus ID generator
132        use_provide_context(|| State::create(ROOT_MENU.0));
133        // Provide the menus stack
134        let mut menus =
135            use_provide_context::<State<Vec<MenuId>>>(|| State::create(vec![ROOT_MENU]));
136        // Provide this the ROOT Menu ID
137        use_provide_context(|| ROOT_MENU);
138
139        let on_escape = self.on_escape.clone().or(self.on_close.clone());
140        let on_global_key_down = move |e: Event<KeyboardEventData>| {
141            if e.key == Key::Named(NamedKey::Escape) {
142                if menus.read().len() > 1 {
143                    menus.write().pop();
144                } else if let Some(on_escape) = &on_escape {
145                    on_escape.call(());
146                }
147            }
148        };
149
150        rect()
151            .layer(Layer::Overlay)
152            .corner_radius(8.0)
153            .on_press(move |ev: Event<PressEventData>| {
154                ev.stop_propagation();
155            })
156            .on_global_pointer_up(move |_: Event<PointerEventData>| {
157                if let Some(on_close) = &self.on_close {
158                    on_close.call(());
159                }
160            })
161            .on_global_key_down(on_global_key_down)
162            .child(
163                MenuContainer::new()
164                    .map(self.theme, |el, theme| el.theme(theme))
165                    .children(self.children),
166            )
167    }
168    fn render_key(&self) -> DiffKey {
169        self.key.clone().or(self.default_key())
170    }
171}
172
173/// Container for menu items with proper spacing and layout.
174///
175/// # Example
176///
177/// ```rust
178/// # use freya::prelude::*;
179/// fn app() -> impl IntoElement {
180///     MenuContainer::new()
181///         .child(MenuItem::new().child("Item 1"))
182///         .child(MenuItem::new().child("Item 2"))
183/// }
184/// ```
185#[derive(Default, Clone, PartialEq)]
186pub struct MenuContainer {
187    pub(crate) theme: Option<MenuContainerThemePartial>,
188    children: Vec<Element>,
189    key: DiffKey,
190}
191
192impl KeyExt for MenuContainer {
193    fn key_mut(&mut self) -> &mut DiffKey {
194        &mut self.key
195    }
196}
197
198impl ChildrenExt for MenuContainer {
199    fn children_mut(&mut self) -> &mut Vec<Element> {
200        &mut self.children
201    }
202}
203
204impl MenuContainer {
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    pub fn theme(mut self, theme: MenuContainerThemePartial) -> Self {
210        self.theme = Some(theme);
211        self
212    }
213}
214
215impl ComponentOwned for MenuContainer {
216    fn render(self) -> impl IntoElement {
217        let a11y_id = use_a11y();
218        let theme = get_theme!(self.theme, MenuContainerThemePreference, "menu_container");
219        let mut measured = use_state(|| None::<(Area, Size2D)>);
220
221        use_provide_context(move || MenuGroup { group_id: a11y_id });
222
223        let (offset_x, offset_y, opacity) = match measured() {
224            None => (0.0, 0.0, 0.0),
225            Some((area, root_size)) => (
226                overflow_offset(area.origin.x, area.size.width, root_size.width),
227                overflow_offset(area.origin.y, area.size.height, root_size.height),
228                1.0,
229            ),
230        };
231
232        rect()
233            .layer(Layer::Overlay)
234            .content(Content::fit())
235            .opacity(opacity)
236            .offset_x(offset_x)
237            .offset_y(offset_y)
238            .on_sized(move |e: Event<SizedEventData>| {
239                if measured.peek().is_none() {
240                    let root_size = *Platform::get().current_window().root_size.peek();
241                    measured.set(Some((e.area, root_size)));
242                }
243            })
244            .child(
245                rect()
246                    .a11y_id(a11y_id)
247                    .a11y_member_of(a11y_id)
248                    .a11y_focusable(true)
249                    .a11y_role(AccessibilityRole::Menu)
250                    .shadow((0.0, 4.0, 10.0, 0., theme.shadow))
251                    .background(theme.background)
252                    .corner_radius(theme.corner_radius)
253                    .padding(theme.padding)
254                    .border(Border::new().width(1.).fill(theme.border_fill))
255                    .content(Content::fit())
256                    .children(self.children),
257            )
258    }
259
260    fn render_key(&self) -> DiffKey {
261        self.key.clone().or(self.default_key())
262    }
263}
264
265#[derive(Clone)]
266pub struct MenuGroup {
267    pub group_id: AccessibilityId,
268}
269
270/// A clickable menu item with hover and focus states.
271///
272/// This is the base component used by MenuButton and SubMenu.
273///
274/// # Example
275///
276/// ```rust
277/// # use freya::prelude::*;
278/// fn app() -> impl IntoElement {
279///     MenuItem::new()
280///         .on_press(|_| println!("Clicked!"))
281///         .child("Open File")
282/// }
283/// ```
284#[derive(Clone, PartialEq)]
285pub struct MenuItem {
286    pub(crate) theme: Option<MenuItemThemePartial>,
287    children: Vec<Element>,
288    on_press: Option<EventHandler<Event<PressEventData>>>,
289    on_pointer_enter: Option<EventHandler<Event<PointerEventData>>>,
290    selected: bool,
291    padding: Gaps,
292    key: DiffKey,
293}
294
295impl Default for MenuItem {
296    fn default() -> Self {
297        Self {
298            theme: None,
299            children: Vec::new(),
300            on_press: None,
301            on_pointer_enter: None,
302            selected: false,
303            padding: (8.0, 14.0).into(),
304            key: DiffKey::None,
305        }
306    }
307}
308
309impl KeyExt for MenuItem {
310    fn key_mut(&mut self) -> &mut DiffKey {
311        &mut self.key
312    }
313}
314
315impl MenuItem {
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    pub fn on_press<F>(mut self, f: F) -> Self
321    where
322        F: Into<EventHandler<Event<PressEventData>>>,
323    {
324        self.on_press = Some(f.into());
325        self
326    }
327
328    pub fn on_pointer_enter<F>(mut self, f: F) -> Self
329    where
330        F: Into<EventHandler<Event<PointerEventData>>>,
331    {
332        self.on_pointer_enter = Some(f.into());
333        self
334    }
335
336    pub fn selected(mut self, selected: bool) -> Self {
337        self.selected = selected;
338        self
339    }
340
341    /// Set the padding for this menu item.
342    pub fn padding(mut self, padding: impl Into<Gaps>) -> Self {
343        self.padding = padding.into();
344        self
345    }
346
347    /// Get the current padding.
348    pub fn get_padding(&self) -> Gaps {
349        self.padding
350    }
351
352    /// Get the theme override for this component.
353    pub fn get_theme(&self) -> Option<&MenuItemThemePartial> {
354        self.theme.as_ref()
355    }
356
357    /// Set a theme override for this component.
358    pub fn theme(mut self, theme: MenuItemThemePartial) -> Self {
359        self.theme = Some(theme);
360        self
361    }
362}
363
364impl ChildrenExt for MenuItem {
365    fn children_mut(&mut self) -> &mut Vec<Element> {
366        &mut self.children
367    }
368}
369
370impl ComponentOwned for MenuItem {
371    fn render(self) -> impl IntoElement {
372        let theme = get_theme!(self.theme, MenuItemThemePreference, "menu_item");
373        let mut hovering = use_state(|| false);
374        let a11y_id = use_a11y();
375        let focus = use_focus(a11y_id);
376        let MenuGroup { group_id } = use_consume::<MenuGroup>();
377
378        let background = if self.selected {
379            theme.select_background
380        } else if hovering() {
381            theme.hover_background
382        } else {
383            theme.background
384        };
385
386        let color = if self.selected {
387            theme.select_color
388        } else {
389            theme.color
390        };
391
392        let border = if focus() == Focus::Keyboard {
393            Border::new()
394                .fill(theme.focus_border_fill)
395                .width(2.)
396                .alignment(BorderAlignment::Inner)
397        } else {
398            Border::new()
399                .fill(theme.border_fill)
400                .width(1.)
401                .alignment(BorderAlignment::Inner)
402        };
403
404        let on_pointer_enter = move |e: Event<PointerEventData>| {
405            hovering.set(true);
406            if let Some(on_pointer_enter) = &self.on_pointer_enter {
407                on_pointer_enter.call(e);
408            }
409        };
410
411        let on_pointer_leave = move |_| {
412            hovering.set(false);
413        };
414
415        let on_press = move |e: Event<PressEventData>| {
416            let prevent_default = e.get_prevent_default();
417            if let Some(on_press) = &self.on_press {
418                on_press.call(e);
419            }
420            if *prevent_default.borrow() {
421                a11y_id.request_focus();
422            }
423        };
424
425        rect()
426            .a11y_role(AccessibilityRole::MenuItem)
427            .a11y_id(a11y_id)
428            .a11y_focusable(true)
429            .a11y_member_of(group_id)
430            .min_width(Size::px(105.))
431            .width(Size::fill_minimum())
432            .content(Content::fit())
433            .padding(self.padding)
434            .corner_radius(theme.corner_radius)
435            .background(background)
436            .border(border)
437            .color(color)
438            .text_align(TextAlign::Start)
439            .main_align(Alignment::Center)
440            .overflow(Overflow::Clip)
441            .on_pointer_enter(on_pointer_enter)
442            .on_pointer_leave(on_pointer_leave)
443            .on_press(on_press)
444            .children(self.children)
445    }
446
447    fn render_key(&self) -> DiffKey {
448        self.key.clone().or(self.default_key())
449    }
450}
451
452/// Like a button, but for Menus.
453///
454/// # Example
455///
456/// ```rust
457/// # use freya::prelude::*;
458/// fn app() -> impl IntoElement {
459///     MenuButton::new()
460///         .on_press(|_| println!("Clicked!"))
461///         .child("Item")
462/// }
463/// ```
464#[derive(Default, Clone, PartialEq)]
465pub struct MenuButton {
466    pub(crate) theme: Option<MenuItemThemePartial>,
467    children: Vec<Element>,
468    on_press: Option<EventHandler<Event<PressEventData>>>,
469    key: DiffKey,
470}
471
472impl ChildrenExt for MenuButton {
473    fn children_mut(&mut self) -> &mut Vec<Element> {
474        &mut self.children
475    }
476}
477
478impl KeyExt for MenuButton {
479    fn key_mut(&mut self) -> &mut DiffKey {
480        &mut self.key
481    }
482}
483
484impl MenuButton {
485    pub fn new() -> Self {
486        Self::default()
487    }
488
489    pub fn on_press(mut self, on_press: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
490        self.on_press = Some(on_press.into());
491        self
492    }
493
494    /// Set a theme override for the inner [`MenuItem`].
495    pub fn theme(mut self, theme: MenuItemThemePartial) -> Self {
496        self.theme = Some(theme);
497        self
498    }
499}
500
501impl ComponentOwned for MenuButton {
502    fn render(self) -> impl IntoElement {
503        let mut menus = use_consume::<State<Vec<MenuId>>>();
504        let parent_menu_id = use_consume::<MenuId>();
505
506        MenuItem::new()
507            .map(self.theme, |el, theme| el.theme(theme))
508            .on_pointer_enter(move |_| close_menus_until(&mut menus, parent_menu_id))
509            .map(self.on_press, |el, on_press| el.on_press(on_press))
510            .children(self.children)
511    }
512
513    fn render_key(&self) -> DiffKey {
514        self.key.clone().or(self.default_key())
515    }
516}
517
518/// Create sub menus inside a Menu.
519///
520/// # Example
521///
522/// ```rust
523/// # use freya::prelude::*;
524/// fn app() -> impl IntoElement {
525///     SubMenu::new()
526///         .label("Export")
527///         .child(MenuButton::new().child("PDF"))
528/// }
529/// ```
530#[derive(Default, Clone, PartialEq)]
531pub struct SubMenu {
532    pub(crate) theme: Option<MenuContainerThemePartial>,
533    label: Option<Element>,
534    items: Vec<Element>,
535    key: DiffKey,
536}
537
538impl KeyExt for SubMenu {
539    fn key_mut(&mut self) -> &mut DiffKey {
540        &mut self.key
541    }
542}
543
544impl SubMenu {
545    pub fn new() -> Self {
546        Self::default()
547    }
548
549    pub fn label(mut self, label: impl IntoElement) -> Self {
550        self.label = Some(label.into_element());
551        self
552    }
553
554    /// Set a theme override for the inner [`MenuContainer`].
555    pub fn theme(mut self, theme: MenuContainerThemePartial) -> Self {
556        self.theme = Some(theme);
557        self
558    }
559}
560
561impl ChildrenExt for SubMenu {
562    fn children_mut(&mut self) -> &mut Vec<Element> {
563        &mut self.items
564    }
565}
566
567impl ComponentOwned for SubMenu {
568    fn render(self) -> impl IntoElement {
569        let parent_menu_id = use_consume::<MenuId>();
570        let mut menus = use_consume::<State<Vec<MenuId>>>();
571        let mut menus_ids_generator = use_consume::<State<usize>>();
572
573        let submenu_id = use_hook(|| {
574            *menus_ids_generator.write() += 1;
575            let menu_id = MenuId(*menus_ids_generator.peek());
576            provide_context(menu_id);
577            menu_id
578        });
579
580        let show_submenu = menus.read().contains(&submenu_id);
581
582        let on_pointer_enter = move |_| {
583            close_menus_until(&mut menus, parent_menu_id);
584            push_menu(&mut menus, submenu_id);
585        };
586
587        let on_press = move |_| {
588            close_menus_until(&mut menus, parent_menu_id);
589            push_menu(&mut menus, submenu_id);
590        };
591
592        MenuItem::new()
593            .on_pointer_enter(on_pointer_enter)
594            .on_press(on_press)
595            .child(rect().horizontal().maybe_child(self.label.clone()))
596            .maybe_child(show_submenu.then(|| {
597                rect()
598                    .position(Position::new_absolute().top(-8.).right(-10.))
599                    .width(Size::px(0.))
600                    .height(Size::px(0.))
601                    .child(
602                        rect().width(Size::window_percent(100.)).child(
603                            MenuContainer::new()
604                                .map(self.theme, |el, theme| el.theme(theme))
605                                .children(self.items),
606                        ),
607                    )
608            }))
609    }
610
611    fn render_key(&self) -> DiffKey {
612        self.key.clone().or(self.default_key())
613    }
614}
615
616/// Returns a negative offset to shift an element back within the window boundary,
617/// or `0.0` if it already fits.
618fn overflow_offset(origin: f32, size: f32, window: f32) -> f32 {
619    let overflow = origin + size - window;
620    if overflow > 0.0 {
621        -overflow.min(origin)
622    } else {
623        0.0
624    }
625}
626
627static ROOT_MENU: MenuId = MenuId(0);
628
629#[derive(Clone, Copy, PartialEq, Eq)]
630struct MenuId(usize);
631
632fn close_menus_until(menus: &mut State<Vec<MenuId>>, until: MenuId) {
633    menus.write().retain(|&id| id.0 <= until.0);
634}
635
636fn push_menu(menus: &mut State<Vec<MenuId>>, id: MenuId) {
637    if !menus.read().contains(&id) {
638        menus.write().push(id);
639    }
640}