Skip to main content

agg_gui/widgets/menu/
widget.rs

1//! Widget adapters for reusable menus.
2//!
3//! `ContextMenu` is a small controller that other widgets can embed, while
4//! `MenuBar` is a visible widget for top-level menus.
5
6use std::sync::Arc;
7
8use crate::draw_ctx::DrawCtx;
9use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
10use crate::geometry::{Point, Rect, Size};
11use crate::text::Font;
12use crate::widget::{current_viewport, Widget};
13
14use super::geometry::{contains, item_at_path, BAR_H};
15use super::model::MenuEntry;
16use super::paint::{paint_menu_bar_button, paint_popup_stack, MenuStyle};
17use super::state::{MenuAnchorKind, MenuResponse, PopupMenuState};
18
19#[derive(Clone)]
20pub struct PopupMenu {
21    pub items: Vec<MenuEntry>,
22    pub state: PopupMenuState,
23    pub style: MenuStyle,
24}
25
26impl PopupMenu {
27    pub fn new(items: Vec<MenuEntry>) -> Self {
28        Self {
29            items,
30            state: PopupMenuState::default(),
31            style: MenuStyle::default(),
32        }
33    }
34
35    pub fn open_at(&mut self, pos: Point) {
36        self.state.open_at(pos, MenuAnchorKind::Context);
37    }
38
39    pub fn close(&mut self) {
40        self.state.close();
41    }
42
43    pub fn is_open(&self) -> bool {
44        self.state.open
45    }
46
47    pub fn take_suppress_mouse_up(&mut self) -> bool {
48        self.state.take_suppress_mouse_up()
49    }
50
51    pub fn handle_event(&mut self, event: &Event, viewport: Size) -> (EventResult, MenuResponse) {
52        self.state.handle_event(&mut self.items, event, viewport)
53    }
54
55    pub fn handle_shortcut(&mut self, key: &Key, modifiers: Modifiers) -> MenuResponse {
56        self.state.handle_shortcut(&mut self.items, key, modifiers)
57    }
58
59    pub fn paint(&self, ctx: &mut dyn DrawCtx, font: Arc<Font>, font_size: f64, viewport: Size) {
60        let layouts = self.state.layouts(&self.items, viewport);
61        paint_popup_stack(
62            ctx,
63            font,
64            font_size,
65            &self.items,
66            &self.state,
67            &layouts,
68            &self.style,
69        );
70    }
71}
72
73pub struct MenuBar {
74    bounds: Rect,
75    children: Vec<Box<dyn Widget>>,
76    font: Arc<Font>,
77    font_size: f64,
78    menus: Vec<TopMenu>,
79    open_index: Option<usize>,
80    hover_index: Option<usize>,
81    popup: PopupMenu,
82    on_action: Box<dyn FnMut(&str)>,
83}
84
85pub struct TopMenu {
86    pub label: String,
87    pub items: Vec<MenuEntry>,
88    rect: Rect,
89}
90
91impl TopMenu {
92    pub fn new(label: impl Into<String>, items: Vec<MenuEntry>) -> Self {
93        Self {
94            label: label.into(),
95            items,
96            rect: Rect::default(),
97        }
98    }
99}
100
101impl MenuBar {
102    pub fn new(
103        font: Arc<Font>,
104        menus: Vec<TopMenu>,
105        on_action: impl FnMut(&str) + 'static,
106    ) -> Self {
107        Self {
108            bounds: Rect::default(),
109            children: Vec::new(),
110            font,
111            font_size: 14.0,
112            menus,
113            open_index: None,
114            hover_index: None,
115            popup: PopupMenu::new(Vec::new()),
116            on_action: Box::new(on_action),
117        }
118    }
119
120    pub fn with_font_size(mut self, font_size: f64) -> Self {
121        self.font_size = font_size;
122        self
123    }
124
125    fn menu_at(&self, pos: Point) -> Option<usize> {
126        self.menus.iter().position(|menu| contains(menu.rect, pos))
127    }
128
129    fn open_menu(&mut self, idx: usize) {
130        let rect = self.menus[idx].rect;
131        self.popup.items = self.menus[idx].items.clone();
132        self.popup
133            .state
134            .open_at(Point::new(rect.x, rect.y), MenuAnchorKind::Bar);
135        self.open_index = Some(idx);
136        self.hover_index = Some(idx);
137        crate::animation::request_draw();
138    }
139
140    fn open_menu_for_drag_release(&mut self, idx: usize) {
141        self.open_menu(idx);
142        self.popup.state.arm_mouse_up_activation();
143    }
144
145    fn switch_open_menu(&mut self, delta: isize) -> EventResult {
146        let Some(current) = self.open_index else {
147            return EventResult::Ignored;
148        };
149        if self.menus.is_empty() {
150            return EventResult::Ignored;
151        }
152        let len = self.menus.len() as isize;
153        let next = (current as isize + delta).rem_euclid(len) as usize;
154        self.open_menu(next);
155        EventResult::Consumed
156    }
157
158    fn should_switch_top_menu(&self, key: &Key) -> bool {
159        match key {
160            Key::ArrowLeft => self.popup.state.open_path.is_empty(),
161            Key::ArrowRight => {
162                if !self.popup.state.open_path.is_empty() {
163                    return false;
164                }
165                self.popup
166                    .state
167                    .hover_path
168                    .as_deref()
169                    .and_then(|path| item_at_path(&self.popup.items, path))
170                    .map_or(true, |item| !item.has_submenu())
171            }
172            _ => false,
173        }
174    }
175
176    fn set_hover_index(&mut self, hover: Option<usize>) {
177        if self.hover_index != hover {
178            self.hover_index = hover;
179            crate::animation::request_draw_without_invalidation();
180        }
181    }
182}
183
184impl Widget for MenuBar {
185    fn type_name(&self) -> &'static str {
186        "MenuBar"
187    }
188
189    fn bounds(&self) -> Rect {
190        self.bounds
191    }
192
193    fn set_bounds(&mut self, bounds: Rect) {
194        self.bounds = bounds;
195    }
196
197    fn children(&self) -> &[Box<dyn Widget>] {
198        &self.children
199    }
200
201    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
202        &mut self.children
203    }
204
205    fn layout(&mut self, available: Size) -> Size {
206        let mut x = 0.0;
207        for menu in &mut self.menus {
208            let width = (menu.label.chars().count() as f64 * 8.0 + 22.0).max(52.0);
209            menu.rect = Rect::new(x, 0.0, width, BAR_H);
210            x += width;
211        }
212        Size::new(available.width, BAR_H)
213    }
214
215    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
216        ctx.set_font(Arc::clone(&self.font));
217        ctx.set_font_size(self.font_size);
218        let v = ctx.visuals();
219        ctx.set_fill_color(v.top_bar_bg);
220        ctx.begin_path();
221        ctx.rect(0.0, 0.0, self.bounds.width, BAR_H);
222        ctx.fill();
223        for (idx, menu) in self.menus.iter().enumerate() {
224            paint_menu_bar_button(
225                ctx,
226                menu.rect,
227                &menu.label,
228                self.open_index == Some(idx),
229                self.hover_index == Some(idx),
230            );
231        }
232    }
233
234    fn hit_test_global_overlay(&self, _local_pos: Point) -> bool {
235        self.popup.is_open()
236    }
237
238    fn has_active_modal(&self) -> bool {
239        self.popup.is_open()
240    }
241
242    fn on_event(&mut self, event: &Event) -> EventResult {
243        if let Event::MouseMove { pos } = event {
244            let hovered = self.menu_at(*pos);
245            self.set_hover_index(hovered);
246            if self.popup.is_open() {
247                if let Some(idx) = hovered {
248                    if self.open_index != Some(idx) {
249                        let activate_on_release = self.popup.state.is_mouse_up_activation_armed();
250                        self.open_menu(idx);
251                        if activate_on_release {
252                            self.popup.state.arm_mouse_up_activation();
253                        }
254                    }
255                    return EventResult::Consumed;
256                }
257            }
258        }
259        if self.popup.is_open() {
260            if let Event::KeyDown { key, .. } = event {
261                if self.should_switch_top_menu(key) {
262                    return match key {
263                        Key::ArrowLeft => self.switch_open_menu(-1),
264                        Key::ArrowRight => self.switch_open_menu(1),
265                        _ => EventResult::Ignored,
266                    };
267                }
268            }
269            let (result, response) = self.popup.handle_event(event, current_viewport());
270            if let MenuResponse::Action(action) = response {
271                if let Some(idx) = self.open_index {
272                    self.menus[idx].items = self.popup.items.clone();
273                }
274                (self.on_action)(&action);
275                if !self.popup.is_open() {
276                    self.open_index = None;
277                }
278            } else if matches!(response, MenuResponse::Closed) {
279                self.open_index = None;
280            }
281            if result == EventResult::Consumed {
282                return result;
283            }
284        }
285        match event {
286            Event::MouseDown {
287                pos,
288                button: MouseButton::Left,
289                ..
290            } => {
291                if let Some(idx) = self.menu_at(*pos) {
292                    self.open_menu_for_drag_release(idx);
293                    EventResult::Consumed
294                } else {
295                    EventResult::Ignored
296                }
297            }
298            Event::MouseMove { .. } => EventResult::Ignored,
299            _ => EventResult::Ignored,
300        }
301    }
302
303    fn on_unconsumed_key(&mut self, key: &Key, modifiers: Modifiers) -> EventResult {
304        let response = if self.popup.is_open() {
305            self.popup.handle_shortcut(key, modifiers)
306        } else {
307            self.menus
308                .iter_mut()
309                .find_map(|menu| {
310                    let mut popup = PopupMenu::new(menu.items.clone());
311                    match popup.handle_shortcut(key, modifiers) {
312                        MenuResponse::Action(action) => {
313                            menu.items = popup.items;
314                            Some(action)
315                        }
316                        MenuResponse::None | MenuResponse::Closed => None,
317                    }
318                })
319                .map(MenuResponse::Action)
320                .unwrap_or(MenuResponse::None)
321        };
322        if let MenuResponse::Action(action) = response {
323            if let Some(idx) = self.open_index {
324                self.menus[idx].items = self.popup.items.clone();
325            }
326            (self.on_action)(&action);
327            if !self.popup.is_open() {
328                self.open_index = None;
329            }
330            EventResult::Consumed
331        } else {
332            EventResult::Ignored
333        }
334    }
335
336    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
337        self.popup.paint(
338            ctx,
339            Arc::clone(&self.font),
340            self.font_size,
341            current_viewport(),
342        );
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::event::{Modifiers, MouseButton};
350    use std::cell::RefCell;
351    use std::rc::Rc;
352
353    fn test_font() -> Arc<Font> {
354        const FONT_BYTES: &[u8] = include_bytes!("../../../../demo/assets/CascadiaCode.ttf");
355        Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
356    }
357
358    #[test]
359    fn moving_across_top_menus_switches_open_popup() {
360        let mut bar = MenuBar::new(
361            test_font(),
362            vec![
363                TopMenu::new(
364                    "File",
365                    vec![super::super::model::MenuItem::action("New", "file.new").into()],
366                ),
367                TopMenu::new(
368                    "Edit",
369                    vec![super::super::model::MenuItem::action("Copy", "edit.copy").into()],
370                ),
371            ],
372            |_| {},
373        );
374        bar.layout(Size::new(300.0, BAR_H));
375
376        assert_eq!(
377            bar.on_event(&Event::MouseDown {
378                pos: Point::new(8.0, 8.0),
379                button: MouseButton::Left,
380                modifiers: Modifiers::default(),
381            }),
382            EventResult::Consumed
383        );
384        assert_eq!(bar.open_index, Some(0));
385
386        assert_eq!(
387            bar.on_event(&Event::MouseMove {
388                pos: Point::new(60.0, 8.0),
389            }),
390            EventResult::Consumed
391        );
392        assert_eq!(bar.open_index, Some(1));
393        let Some(super::super::model::MenuEntry::Item(item)) = bar.popup.items.first() else {
394            panic!("popup should contain Edit items");
395        };
396        assert_eq!(item.action.as_deref(), Some("edit.copy"));
397    }
398
399    #[test]
400    fn top_level_menu_tracks_hover() {
401        let mut bar = MenuBar::new(
402            test_font(),
403            vec![TopMenu::new(
404                "File",
405                vec![super::super::model::MenuItem::action("New", "file.new").into()],
406            )],
407            |_| {},
408        );
409        bar.layout(Size::new(300.0, BAR_H));
410
411        assert_eq!(
412            bar.on_event(&Event::MouseMove {
413                pos: Point::new(8.0, 8.0),
414            }),
415            EventResult::Ignored
416        );
417        assert_eq!(bar.hover_index, Some(0));
418    }
419
420    #[test]
421    fn mouse_down_drag_release_activates_popup_item() {
422        let viewport = Size::new(300.0, 180.0);
423        crate::widget::set_current_viewport(viewport);
424        let actions = Rc::new(RefCell::new(Vec::new()));
425        let actions_for_cb = Rc::clone(&actions);
426        let mut bar = MenuBar::new(
427            test_font(),
428            vec![TopMenu::new(
429                "File",
430                vec![super::super::model::MenuItem::action("New", "file.new").into()],
431            )],
432            move |action| actions_for_cb.borrow_mut().push(action.to_string()),
433        );
434        bar.layout(Size::new(300.0, BAR_H));
435
436        assert_eq!(
437            bar.on_event(&Event::MouseDown {
438                pos: Point::new(8.0, 8.0),
439                button: MouseButton::Left,
440                modifiers: Modifiers::default(),
441            }),
442            EventResult::Consumed
443        );
444        let row = bar.popup.state.layouts(&bar.popup.items, viewport)[0].rows[0].rect;
445        let item_pos = Point::new(row.x + 12.0, row.y + 12.0);
446
447        assert_eq!(
448            bar.on_event(&Event::MouseMove { pos: item_pos }),
449            EventResult::Consumed
450        );
451        assert_eq!(
452            bar.on_event(&Event::MouseUp {
453                pos: item_pos,
454                button: MouseButton::Left,
455                modifiers: Modifiers::default(),
456            }),
457            EventResult::Consumed
458        );
459
460        assert_eq!(actions.borrow().as_slice(), ["file.new"]);
461        assert!(!bar.popup.is_open());
462    }
463
464    #[test]
465    fn simple_mouse_click_opens_menu_without_release_activation() {
466        let viewport = Size::new(300.0, 180.0);
467        crate::widget::set_current_viewport(viewport);
468        let mut bar = MenuBar::new(
469            test_font(),
470            vec![TopMenu::new(
471                "File",
472                vec![super::super::model::MenuItem::action("New", "file.new").into()],
473            )],
474            |_| {},
475        );
476        bar.layout(Size::new(300.0, BAR_H));
477
478        assert_eq!(
479            bar.on_event(&Event::MouseDown {
480                pos: Point::new(8.0, 8.0),
481                button: MouseButton::Left,
482                modifiers: Modifiers::default(),
483            }),
484            EventResult::Consumed
485        );
486        assert_eq!(
487            bar.on_event(&Event::MouseUp {
488                pos: Point::new(8.0, 8.0),
489                button: MouseButton::Left,
490                modifiers: Modifiers::default(),
491            }),
492            EventResult::Consumed
493        );
494
495        assert!(bar.popup.is_open());
496        assert_eq!(bar.open_index, Some(0));
497    }
498
499    #[test]
500    fn unconsumed_shortcut_fires_top_menu_action() {
501        let actions = Rc::new(RefCell::new(Vec::new()));
502        let actions_for_cb = Rc::clone(&actions);
503        let mut bar = MenuBar::new(
504            test_font(),
505            vec![TopMenu::new(
506                "File",
507                vec![super::super::model::MenuItem::action("New", "file.new")
508                    .shortcut("Ctrl+N")
509                    .into()],
510            )],
511            move |action| actions_for_cb.borrow_mut().push(action.to_string()),
512        );
513
514        assert_eq!(
515            bar.on_unconsumed_key(
516                &Key::Char('n'),
517                Modifiers {
518                    ctrl: true,
519                    ..Modifiers::default()
520                },
521            ),
522            EventResult::Consumed
523        );
524
525        assert_eq!(actions.borrow().as_slice(), ["file.new"]);
526    }
527
528    #[test]
529    fn arrow_keys_switch_open_top_menus() {
530        let mut bar = MenuBar::new(
531            test_font(),
532            vec![
533                TopMenu::new(
534                    "File",
535                    vec![super::super::model::MenuItem::action("New", "file.new").into()],
536                ),
537                TopMenu::new(
538                    "Edit",
539                    vec![super::super::model::MenuItem::action("Copy", "edit.copy").into()],
540                ),
541            ],
542            |_| {},
543        );
544        bar.layout(Size::new(300.0, BAR_H));
545        bar.open_menu(0);
546
547        assert_eq!(
548            bar.on_event(&Event::KeyDown {
549                key: Key::ArrowRight,
550                modifiers: Modifiers::default(),
551            }),
552            EventResult::Consumed
553        );
554        assert_eq!(bar.open_index, Some(1));
555
556        assert_eq!(
557            bar.on_event(&Event::KeyDown {
558                key: Key::ArrowLeft,
559                modifiers: Modifiers::default(),
560            }),
561            EventResult::Consumed
562        );
563        assert_eq!(bar.open_index, Some(0));
564    }
565}