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