Skip to main content

vizia_core/views/
menu.rs

1use crate::modifiers::ModalEvent;
2use crate::style::{Abilities, Display};
3use crate::{icons::ICON_CHEVRON_RIGHT, prelude::*};
4
5fn first_focusable_descendant(tree: &Tree<Entity>, style: &Style, root: Entity) -> Option<Entity> {
6    vizia_storage::TreeIterator::subtree(tree, root).skip(1).find(|node| {
7        if style.display.get(*node).copied().unwrap_or_default() == Display::None {
8            return false;
9        }
10        if style.disabled.get(*node).copied().unwrap_or_default() {
11            return false;
12        }
13        style
14            .abilities
15            .get(*node)
16            .map(|abilities| abilities.contains(Abilities::FOCUSABLE))
17            .unwrap_or(false)
18    })
19}
20
21fn is_focusable_item(cx: &EventContext, entity: Entity) -> bool {
22    if cx.style.display.get(entity).copied().unwrap_or_default() == Display::None {
23        return false;
24    }
25
26    if cx.style.disabled.get(entity).copied().unwrap_or_default() {
27        return false;
28    }
29
30    cx.style
31        .abilities
32        .get(entity)
33        .map(|abilities| abilities.contains(Abilities::FOCUSABLE))
34        .unwrap_or(false)
35}
36
37fn first_focusable_child(cx: &EventContext, root: Entity) -> Option<Entity> {
38    let mut child = cx.tree.get_first_child(root);
39    while let Some(entity) = child {
40        if is_focusable_item(cx, entity) {
41            return Some(entity);
42        }
43        child = cx.tree.get_next_sibling(entity);
44    }
45
46    None
47}
48
49fn first_menu_bar_item(cx: &EventContext, root: Entity) -> Option<Entity> {
50    first_focusable_child(cx, root)
51}
52
53/// A view which represents a horizontal group of menus.
54pub struct MenuBar {
55    is_open: Signal<bool>,
56    focused_item: Signal<Option<Entity>>,
57}
58
59impl MenuBar {
60    /// Creates a new [MenuBar] view.
61    pub fn new(cx: &mut Context, content: impl Fn(&mut Context)) -> Handle<Self> {
62        let is_open = Signal::new(false);
63        let focused_item = Signal::new(None);
64
65        Self { is_open, focused_item }
66            .build(cx, |cx| {
67                cx.add_listener(move |menu_bar: &mut Self, cx, event| {
68                    let flag = menu_bar.is_open.get();
69                    event.map(
70                        |window_event, meta: &mut crate::events::EventMeta| match window_event {
71                            WindowEvent::MouseDown(_) => {
72                                if flag && meta.origin != cx.current() {
73                                    // Check if the mouse was pressed outside of any descendants
74                                    if !cx.hovered.is_descendant_of(cx.tree, cx.current) {
75                                        cx.emit(MenuEvent::CloseAll);
76                                    }
77                                }
78                            }
79
80                            _ => {}
81                        },
82                    );
83                });
84
85                (content)(cx);
86            })
87            .layout_type(LayoutType::Row)
88            .role(Role::MenuBar)
89            .orientation(Orientation::Horizontal)
90            .navigable(true)
91    }
92}
93
94impl View for MenuBar {
95    fn element(&self) -> Option<&'static str> {
96        Some("menubar")
97    }
98
99    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
100        event.map(|window_event, meta| match window_event {
101            WindowEvent::FocusIn => {
102                if meta.target == cx.current() {
103                    if let Some(first_item) = first_menu_bar_item(cx, cx.current()) {
104                        focus_entity(cx, first_item);
105                        self.focused_item.set(Some(first_item));
106                        meta.consume();
107                    }
108                }
109            }
110
111            WindowEvent::KeyDown(Code::ArrowLeft, _) => {
112                if !self.is_open.get() {
113                    cx.emit(MenuEvent::FocusPrevMenuBarItem);
114
115                    meta.consume();
116                }
117            }
118
119            WindowEvent::KeyDown(Code::ArrowRight, _) => {
120                if !self.is_open.get() {
121                    cx.emit(MenuEvent::FocusNextMenuBarItem);
122                    meta.consume();
123                }
124            }
125            _ => {}
126        });
127
128        event.map(|menu_event, _| match menu_event {
129            MenuEvent::MenuIsOpen => {
130                self.is_open.set_if_changed(true);
131            }
132
133            MenuEvent::CloseAll => {
134                self.is_open.set_if_changed(false);
135                cx.emit_custom(
136                    Event::new(MenuEvent::Close).target(cx.current).propagate(Propagation::Subtree),
137                );
138            }
139
140            MenuEvent::FocusPrevMenuBarItem => {
141                if let Some(current) = self.focused_item.get() {
142                    if let Some(next) = prev_sibling_wrapped(cx, current) {
143                        focus_entity(cx, next);
144                        if self.is_open.get() {
145                            cx.emit_custom(
146                                Event::new(MenuEvent::Close)
147                                    .target(current)
148                                    .propagate(Propagation::Subtree),
149                            );
150                            cx.emit_custom(
151                                Event::new(MenuEvent::TriggerArrowDown)
152                                    .target(next)
153                                    .propagate(Propagation::Direct),
154                            );
155                        }
156                        self.focused_item.set(Some(next));
157                    }
158                }
159            }
160
161            MenuEvent::FocusNextMenuBarItem => {
162                if let Some(current) = self.focused_item.get() {
163                    if let Some(next) = next_sibling_wrapped(cx, current) {
164                        focus_entity(cx, next);
165                        if self.is_open.get() {
166                            cx.emit_custom(
167                                Event::new(MenuEvent::Close)
168                                    .target(current)
169                                    .propagate(Propagation::Subtree),
170                            );
171                            cx.emit_custom(
172                                Event::new(MenuEvent::TriggerArrowDown)
173                                    .target(next)
174                                    .propagate(Propagation::Direct),
175                            );
176                        }
177                        self.focused_item.set(Some(next));
178                    }
179                }
180            }
181
182            _ => {}
183        });
184    }
185}
186
187/// Events used by menus.
188pub enum MenuEvent {
189    /// Toggle the open state of the menu.
190    ToggleOpen,
191    /// Sets the menu to an open state.
192    Open,
193    /// Sets the menu to a closed state.
194    Close,
195    /// Closes the active menu and restores focus to the trigger that opened it.
196    CloseAndFocusTrigger,
197    /// Closes the menu and any submenus.
198    CloseAll,
199    /// Event emitted when a menu or submenu is opened.
200    MenuIsOpen,
201    /// Move focus to the next item within the open popup.
202    FocusNext,
203    /// Move focus to the previous item within the open popup.
204    FocusPrev,
205    /// Move focus to the first item within the open popup.
206    FocusFirst,
207    /// Move focus to the last item within the open popup.
208    FocusLast,
209    /// ArrowDown pressed on a submenu trigger (open popup or within popup).
210    TriggerArrowDown,
211    /// ArrowRight pressed on a submenu trigger (navigate or open submenu).
212    TriggerArrowRight,
213    /// ArrowLeft pressed on a submenu trigger (navigate or close submenu).
214    TriggerArrowLeft,
215    /// Focus the next top-level menu bar item (used when pressing arrow keys on menu bar items).
216    FocusNextMenuBarItem,
217    /// Focus the previous top-level menu bar item (used when pressing arrow keys on menu bar items).
218    FocusPrevMenuBarItem,
219}
220
221fn focus_entity(cx: &mut EventContext, entity: Entity) {
222    cx.with_current(entity, |cx| cx.focus());
223}
224
225fn next_sibling_wrapped(cx: &EventContext, entity: Entity) -> Option<Entity> {
226    let parent = cx.tree.get_parent(entity)?;
227    let mut next = cx.tree.get_next_sibling(entity).or_else(|| cx.tree.get_first_child(parent));
228
229    while let Some(candidate) = next {
230        if candidate == entity {
231            break;
232        }
233
234        if is_focusable_item(cx, candidate) {
235            return Some(candidate);
236        }
237
238        next = cx.tree.get_next_sibling(candidate).or_else(|| cx.tree.get_first_child(parent));
239    }
240
241    None
242}
243
244fn prev_sibling_wrapped(cx: &EventContext, entity: Entity) -> Option<Entity> {
245    let parent = cx.tree.get_parent(entity)?;
246    let mut prev =
247        cx.tree.get_prev_sibling(entity).or_else(|| cx.tree.get_last_child(parent).copied());
248
249    while let Some(candidate) = prev {
250        if candidate == entity {
251            break;
252        }
253
254        if is_focusable_item(cx, candidate) {
255            return Some(candidate);
256        }
257
258        prev =
259            cx.tree.get_prev_sibling(candidate).or_else(|| cx.tree.get_last_child(parent).copied());
260    }
261
262    None
263}
264
265fn focus_next_sibling_wrapped(cx: &mut EventContext) -> bool {
266    let current = cx.current();
267    if let Some(next) = next_sibling_wrapped(cx, current) {
268        focus_entity(cx, next);
269        return true;
270    }
271
272    false
273}
274
275fn focus_prev_sibling_wrapped(cx: &mut EventContext) -> bool {
276    let current = cx.current();
277    if let Some(prev) = prev_sibling_wrapped(cx, current) {
278        focus_entity(cx, prev);
279        return true;
280    }
281
282    false
283}
284
285fn focus_first_sibling(cx: &mut EventContext) -> bool {
286    let current = cx.current();
287    let Some(parent) = cx.tree.get_parent(current) else {
288        return false;
289    };
290
291    if let Some(first) = cx.tree.get_first_child(parent) {
292        focus_entity(cx, first);
293        return true;
294    }
295
296    false
297}
298
299fn focus_last_sibling(cx: &mut EventContext) -> bool {
300    let current = cx.current();
301    let Some(parent) = cx.tree.get_parent(current) else {
302        return false;
303    };
304
305    if let Some(last) = cx.tree.get_last_child(parent).copied() {
306        focus_entity(cx, last);
307        return true;
308    }
309
310    false
311}
312
313/// A popup menu view used by submenus and context menus.
314pub struct Menu {}
315
316impl Menu {
317    /// Creates a new [Menu] popup.
318    pub fn new(
319        cx: &mut Context,
320        placement: impl Res<Placement> + 'static,
321        focus_on_open: impl Res<bool> + 'static,
322        content: impl Fn(&mut Context),
323    ) -> Handle<'_, Popover> {
324        let focus_on_open = focus_on_open.to_signal(cx);
325
326        Popover::new(cx, move |cx| {
327            let popup = cx.current();
328
329            // Keymap scoped to this popup: handles in-menu keyboard navigation.
330            Keymap::from(vec![
331                (
332                    KeyChord::new(Modifiers::empty(), Code::ArrowDown),
333                    KeymapEntry::new("Focus Next", |cx| cx.emit(MenuEvent::FocusNext)),
334                ),
335                (
336                    KeyChord::new(Modifiers::empty(), Code::ArrowUp),
337                    KeymapEntry::new("Focus Prev", |cx| cx.emit(MenuEvent::FocusPrev)),
338                ),
339                (
340                    KeyChord::new(Modifiers::empty(), Code::Home),
341                    KeymapEntry::new("Focus First", |cx| cx.emit(MenuEvent::FocusFirst)),
342                ),
343                (
344                    KeyChord::new(Modifiers::empty(), Code::End),
345                    KeymapEntry::new("Focus Last", |cx| cx.emit(MenuEvent::FocusLast)),
346                ),
347                (
348                    KeyChord::new(Modifiers::empty(), Code::Escape),
349                    KeymapEntry::new("Close Active Menu", |cx| {
350                        cx.emit(MenuEvent::CloseAndFocusTrigger)
351                    }),
352                ),
353                (
354                    KeyChord::new(Modifiers::empty(), Code::ArrowLeft),
355                    KeymapEntry::new("Close", |cx| cx.emit(MenuEvent::TriggerArrowLeft)),
356                ),
357                (
358                    KeyChord::new(Modifiers::empty(), Code::Tab),
359                    KeymapEntry::new("Close All", |cx| cx.emit(MenuEvent::CloseAll)),
360                ),
361            ])
362            .build(cx);
363
364            (content)(cx);
365
366            if focus_on_open.get() {
367                if let Some(first_item) = first_focusable_descendant(&cx.tree, &cx.style, popup) {
368                    cx.with_current(first_item, |cx| cx.focus());
369                }
370            }
371        })
372        .role(Role::Menu)
373        .lock_focus_to_within()
374        .placement(placement)
375        .arrow_size(Pixels(0.0))
376    }
377}
378
379impl View for Menu {
380    fn element(&self) -> Option<&'static str> {
381        Some("menu")
382    }
383}
384
385/// A view which represents a submenu within a menu.
386pub struct Submenu {
387    is_open: Signal<bool>,
388    focus_on_open: Signal<bool>,
389    open_on_hover: bool,
390    is_submenu: bool,
391}
392
393impl Submenu {
394    /// Creates a new [Submenu] view.
395    pub fn new<V: View>(
396        cx: &mut Context,
397        content: impl Fn(&mut Context) -> Handle<V> + 'static,
398        menu: impl Fn(&mut Context) + 'static,
399    ) -> Handle<Self> {
400        let is_submenu = cx.try_data::<Submenu>().is_some();
401        let is_menu_bar_item = cx.try_data::<MenuBar>().is_some();
402
403        let is_open = Signal::new(false);
404        let focus_on_open = Signal::new(false);
405        let submenu_popup_placement =
406            if is_submenu { Placement::RightStart } else { Placement::BottomStart };
407
408        let handle = Self { is_open, focus_on_open, open_on_hover: is_submenu, is_submenu }
409            .build(cx, |cx| {
410                cx.add_listener(move |menu_button: &mut Self, cx, event| {
411                    let flag = menu_button.is_open.get();
412                    event.map(
413                        |window_event, meta: &mut crate::events::EventMeta| match window_event {
414                            WindowEvent::MouseDown(_) => {
415                                if flag && meta.origin != cx.current() {
416                                    // Check if the mouse was pressed outside of any descendants
417                                    if !cx.hovered.is_descendant_of(cx.tree, cx.current) {
418                                        cx.emit(MenuEvent::CloseAll);
419                                        cx.emit(MenuEvent::Close);
420                                    }
421                                }
422                            }
423
424                            _ => {}
425                        },
426                    );
427                });
428
429                // Keymap for the submenu trigger itself: handles arrow keys and tab.
430                Keymap::from(vec![
431                    (
432                        KeyChord::new(Modifiers::empty(), Code::ArrowDown),
433                        KeymapEntry::new("Open Submenu", |cx| cx.emit(MenuEvent::TriggerArrowDown)),
434                    ),
435                    (
436                        KeyChord::new(Modifiers::empty(), Code::Space),
437                        KeymapEntry::new("Open Submenu", |cx| cx.emit(MenuEvent::TriggerArrowDown)),
438                    ),
439                    (
440                        KeyChord::new(Modifiers::empty(), Code::Enter),
441                        KeymapEntry::new("Open Submenu", |cx| cx.emit(MenuEvent::TriggerArrowDown)),
442                    ),
443                    (
444                        KeyChord::new(Modifiers::empty(), Code::ArrowRight),
445                        KeymapEntry::new("Navigate Right", |cx| {
446                            cx.emit(MenuEvent::TriggerArrowRight)
447                        }),
448                    ),
449                ])
450                .build(cx);
451
452                (content)(cx).hoverable(false);
453                Svg::new(cx, ICON_CHEVRON_RIGHT).class("arrow").hoverable(false);
454
455                Binding::new(cx, is_open, move |cx| {
456                    let open = is_open.get();
457                    if open {
458                        Menu::new(cx, submenu_popup_placement, focus_on_open, |cx| (menu)(cx))
459                            .checked(is_open)
460                            .on_hover(|cx| {
461                                cx.emit_custom(
462                                    Event::new(MenuEvent::Close)
463                                        .target(cx.current)
464                                        .propagate(Propagation::Subtree),
465                                )
466                            });
467                    }
468                });
469            })
470            .focusable(true)
471            .navigable(!is_menu_bar_item && !is_submenu)
472            .role(Role::MenuItem)
473            .checked(is_open)
474            .expanded(is_open)
475            .layout_type(LayoutType::Row)
476            .on_press(|cx| cx.emit(MenuEvent::ToggleOpen));
477
478        if handle.try_data::<MenuBar>().is_some() {
479            let menu_bar_open = handle.data::<MenuBar>().is_open;
480            handle.bind(menu_bar_open, move |handle| {
481                let is_open = menu_bar_open.get();
482                handle.modify(|menu_button| menu_button.open_on_hover = is_open);
483            })
484        } else {
485            handle
486        }
487    }
488}
489
490impl View for Submenu {
491    fn element(&self) -> Option<&'static str> {
492        Some("submenu")
493    }
494
495    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
496        event.map(|window_event, meta| match window_event {
497            WindowEvent::MouseEnter => {
498                if meta.target == cx.current && self.open_on_hover {
499                    // Close any open submenus of the parent.
500                    let parent = cx.tree.get_parent(cx.current).unwrap();
501                    cx.emit_custom(
502                        Event::new(MenuEvent::Close).target(parent).propagate(Propagation::Subtree),
503                    );
504                    // cx.focus();
505                    self.focus_on_open.set(false);
506                    cx.emit(MenuEvent::Open);
507                }
508            }
509
510            _ => {}
511        });
512
513        event.map(|menu_event, meta| match menu_event {
514            MenuEvent::TriggerArrowDown => {
515                let popup_open = self.is_open.get();
516                if popup_open {
517                    // Popup keymap will handle the navigation; just consume here to stop propagation.
518                    meta.consume();
519                } else if !self.is_submenu {
520                    // Top-level menubar item: open the popup.
521                    self.focus_on_open.set(true);
522                    self.is_open.set(true);
523                    cx.emit(MenuEvent::MenuIsOpen);
524                    meta.consume();
525                }
526            }
527
528            MenuEvent::TriggerArrowRight => {
529                if self.is_submenu {
530                    let popup_open = self.is_open.get();
531                    if !popup_open {
532                        self.focus_on_open.set(true);
533                        self.is_open.set(true);
534                        cx.emit(MenuEvent::MenuIsOpen);
535                    }
536                }
537                meta.consume();
538            }
539
540            MenuEvent::TriggerArrowLeft => {
541                // Check if that Submenu is a direct child of MenuBar.
542                let Some(parent_of_trigger) = cx.tree.get_parent(cx.current()) else {
543                    return;
544                };
545
546                let is_direct_submenu_of_menubar =
547                    cx.get_view_with::<MenuBar>(parent_of_trigger).is_some();
548
549                if is_direct_submenu_of_menubar {
550                    cx.emit(MenuEvent::FocusPrevMenuBarItem);
551                } else {
552                    cx.emit(MenuEvent::CloseAndFocusTrigger);
553                }
554
555                meta.consume();
556            }
557            MenuEvent::Open => {
558                if !self.is_open.get() {
559                    self.focus_on_open.set(false);
560                    self.is_open.set(true);
561                    cx.emit(MenuEvent::MenuIsOpen);
562                }
563                meta.consume();
564            }
565
566            MenuEvent::CloseAll => {
567                self.is_open.set_if_changed(false);
568                cx.emit_custom(
569                    Event::new(MenuEvent::Close).target(cx.current).propagate(Propagation::Subtree),
570                );
571            }
572
573            MenuEvent::Close => {
574                self.is_open.set_if_changed(false);
575            }
576
577            MenuEvent::CloseAndFocusTrigger => {
578                self.is_open.set_if_changed(false);
579                cx.focus();
580                if !self.is_submenu {
581                    cx.emit(MenuEvent::CloseAll);
582                }
583                meta.consume();
584            }
585
586            // Focus navigation — dispatched by the popup-scoped Keymap.
587            // We run the helper in the context of the currently focused entity so
588            // sibling-relative movement is correct regardless of nesting depth.
589            MenuEvent::FocusNext => {
590                let focused = cx.focused();
591                cx.with_current(focused, |cx| {
592                    focus_next_sibling_wrapped(cx);
593                });
594                meta.consume();
595            }
596
597            MenuEvent::FocusPrev => {
598                let focused = cx.focused();
599                cx.with_current(focused, |cx| {
600                    focus_prev_sibling_wrapped(cx);
601                });
602                meta.consume();
603            }
604
605            MenuEvent::FocusFirst => {
606                let focused = cx.focused();
607                cx.with_current(focused, |cx| {
608                    focus_first_sibling(cx);
609                });
610                meta.consume();
611            }
612
613            MenuEvent::FocusLast => {
614                let focused = cx.focused();
615                cx.with_current(focused, |cx| {
616                    focus_last_sibling(cx);
617                });
618                meta.consume();
619            }
620
621            MenuEvent::ToggleOpen => {
622                let is_open = !self.is_open.get();
623                self.is_open.set(is_open);
624                if is_open {
625                    self.focus_on_open.set(false);
626                    cx.emit(MenuEvent::MenuIsOpen);
627                } else {
628                    // If the parent is a MenuBar then this will reset the is_open state.
629                    let parent = cx.tree.get_parent(cx.current).unwrap();
630                    cx.emit_custom(
631                        Event::new(MenuEvent::CloseAll)
632                            .target(parent)
633                            .propagate(Propagation::Direct),
634                    );
635                }
636                meta.consume();
637            }
638
639            _ => {}
640        });
641    }
642}
643
644/// A view which represents a pressable item within a menu.
645pub struct MenuButton {}
646
647impl MenuButton {
648    /// Creates a new [MenuButton] view.
649    pub fn new<V: View>(
650        cx: &mut Context,
651        action: impl Fn(&mut EventContext) + Send + Sync + 'static,
652        content: impl Fn(&mut Context) -> Handle<V> + 'static,
653    ) -> Handle<Self> {
654        Self {}
655            .build(cx, |cx| {
656                (content)(cx).hoverable(false);
657            })
658            .on_press(move |cx| {
659                (action)(cx);
660                cx.emit(MenuEvent::CloseAll);
661                cx.emit(ModalEvent::HideMenu);
662                cx.emit(MenuEvent::Close);
663            })
664            .focusable(true)
665            .role(Role::MenuItem)
666            .navigable(false)
667    }
668}
669
670impl View for MenuButton {
671    fn element(&self) -> Option<&'static str> {
672        Some("menubutton")
673    }
674
675    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
676        event.map(|window_event, meta| match window_event {
677            WindowEvent::MouseEnter => {
678                if meta.target == cx.current {
679                    let parent = cx.tree.get_parent(cx.current).unwrap();
680                    cx.emit_custom(
681                        Event::new(MenuEvent::Close).target(parent).propagate(Propagation::Subtree),
682                    );
683                }
684            }
685
686            WindowEvent::KeyDown(Code::ArrowRight, _) => {
687                cx.emit(MenuEvent::FocusNextMenuBarItem);
688            }
689
690            _ => {}
691        });
692    }
693}