Skip to main content

agg_gui/widgets/menu/
mod.rs

1//! Reusable menu infrastructure.
2//!
3//! This module provides the shared model, geometry, state, painter, and widget
4//! adapters used by context menus and top menu bars.
5
6pub mod geometry;
7pub mod model;
8pub mod paint;
9pub mod state;
10pub mod strip;
11pub mod widget;
12
13pub use geometry::{BAR_H as MENU_BAR_H, MENU_W, ROW_H};
14pub use model::{MenuEntry, MenuItem, MenuSelection, MenuShortcut, ShortcutKey};
15pub use paint::MenuStyle;
16pub use state::{MenuAnchorKind, MenuResponse, PopupMenuState};
17pub use strip::MenuBarStrip;
18pub use widget::{MenuBar, MenuOrientation, PopupMenu, TopMenu, VERTICAL_ROW_H};
19
20#[cfg(test)]
21mod tests {
22    use crate::event::{Event, Key, Modifiers, MouseButton};
23    use crate::geometry::{Point, Size};
24
25    use super::geometry::{hit_test, stack_layout, MenuHit};
26    use super::paint::submenu_chevron_points;
27    use super::*;
28    use crate::geometry::Rect;
29
30    fn test_items() -> Vec<MenuEntry> {
31        vec![
32            MenuItem::action("Open", "open")
33                .icon('\u{f07c}')
34                .shortcut("Ctrl+O")
35                .into(),
36            MenuItem::action("Disabled", "disabled").disabled().into(),
37            MenuEntry::Separator,
38            MenuItem::submenu(
39                "More",
40                vec![
41                    MenuItem::action("Leaf", "leaf").into(),
42                    MenuItem::action("Checked", "checked").checked(true).into(),
43                ],
44            )
45            .into(),
46        ]
47    }
48
49    #[test]
50    fn popup_clamps_to_viewport() {
51        let items = test_items();
52        let layouts = stack_layout(
53            &items,
54            Point::new(500.0, -50.0),
55            MenuAnchorKind::Context,
56            &[],
57            Size::new(240.0, 120.0),
58        );
59        let rect = layouts[0].rect;
60        assert!(rect.x >= 4.0);
61        assert!(rect.y >= 4.0);
62        assert!(rect.x + rect.width <= 240.0);
63        assert!(rect.y + rect.height <= 120.0);
64    }
65
66    #[test]
67    fn menu_bar_popups_can_open_below_the_bar() {
68        let items = test_items();
69        let layouts = stack_layout(
70            &items,
71            Point::new(20.0, 0.0),
72            MenuAnchorKind::Bar,
73            &[],
74            Size::new(400.0, 240.0),
75        );
76        assert!(
77            layouts[0].rect.y < 0.0,
78            "bar popups use negative local Y so they paint below a top menu bar"
79        );
80    }
81
82    #[test]
83    fn hover_opens_submenu_and_hit_tests_nested_rows() {
84        let items = test_items();
85        let mut state = PopupMenuState::default();
86        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
87        let viewport = Size::new(400.0, 240.0);
88        let layouts = state.layouts(&items, viewport);
89        let more_row = layouts[0].rows[3].rect;
90
91        assert!(state.update_hover(
92            &items,
93            Point::new(more_row.x + 10.0, more_row.y + 10.0),
94            viewport
95        ));
96        assert_eq!(state.open_path, vec![3]);
97
98        let layouts = state.layouts(&items, viewport);
99        let submenu_row = layouts[1].rows[0].rect;
100        assert!(matches!(
101            hit_test(
102                &layouts,
103                Point::new(submenu_row.x + 10.0, submenu_row.y + 10.0)
104            ),
105            Some(MenuHit::Item(path)) if path == vec![3, 0]
106        ));
107    }
108
109    #[test]
110    fn action_click_consumes_and_suppresses_followup_mouse_up() {
111        let mut items = test_items();
112        let mut state = PopupMenuState::default();
113        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
114        let viewport = Size::new(400.0, 240.0);
115        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
116
117        let (_, response) = state.handle_event(
118            &mut items,
119            &Event::MouseDown {
120                pos: Point::new(first_row.x + 10.0, first_row.y + 10.0),
121                button: MouseButton::Left,
122                modifiers: Modifiers::default(),
123            },
124            viewport,
125        );
126        assert_eq!(response, MenuResponse::Action("open".to_string()));
127        assert!(state.take_suppress_mouse_up());
128    }
129
130    #[test]
131    fn keep_open_check_and_radio_actions_do_not_close() {
132        let mut items = vec![
133            MenuItem::action("Check", "check")
134                .checked(false)
135                .keep_open()
136                .into(),
137            MenuItem::action("Radio A", "radio-a")
138                .radio(true)
139                .keep_open()
140                .into(),
141            MenuItem::action("Radio B", "radio-b")
142                .radio(false)
143                .keep_open()
144                .into(),
145        ];
146        let mut state = PopupMenuState::default();
147        state.open_at(Point::new(20.0, 120.0), MenuAnchorKind::Context);
148        let viewport = Size::new(300.0, 200.0);
149        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
150
151        let (_, response) = state.handle_event(
152            &mut items,
153            &Event::MouseDown {
154                pos: Point::new(first_row.x + 10.0, first_row.y + 10.0),
155                button: MouseButton::Left,
156                modifiers: Modifiers::default(),
157            },
158            viewport,
159        );
160
161        assert_eq!(response, MenuResponse::Action("check".to_string()));
162        assert!(state.open);
163        assert!(!state.should_suppress_mouse_up());
164        let MenuEntry::Item(item) = &items[0] else {
165            panic!("first row should be an item");
166        };
167        assert_eq!(item.selection, MenuSelection::Check { selected: true });
168
169        let third_row = state.layouts(&items, viewport)[0].rows[2].rect;
170        let (_, response) = state.handle_event(
171            &mut items,
172            &Event::MouseDown {
173                pos: Point::new(third_row.x + 10.0, third_row.y + 10.0),
174                button: MouseButton::Left,
175                modifiers: Modifiers::default(),
176            },
177            viewport,
178        );
179        assert_eq!(response, MenuResponse::Action("radio-b".to_string()));
180        assert!(state.open);
181        let MenuEntry::Item(item) = &items[1] else {
182            panic!("second row should be an item");
183        };
184        assert_eq!(item.selection, MenuSelection::Radio { selected: false });
185        let MenuEntry::Item(item) = &items[2] else {
186            panic!("third row should be an item");
187        };
188        assert_eq!(item.selection, MenuSelection::Radio { selected: true });
189    }
190
191    #[test]
192    fn disabled_rows_do_not_fire_actions() {
193        let mut items = test_items();
194        let mut state = PopupMenuState::default();
195        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
196        let viewport = Size::new(400.0, 240.0);
197        let disabled_row = state.layouts(&items, viewport)[0].rows[1].rect;
198
199        let (_, response) = state.handle_event(
200            &mut items,
201            &Event::MouseDown {
202                pos: Point::new(disabled_row.x + 10.0, disabled_row.y + 10.0),
203                button: MouseButton::Left,
204                modifiers: Modifiers::default(),
205            },
206            viewport,
207        );
208        assert_eq!(response, MenuResponse::None);
209        assert!(state.open);
210    }
211
212    #[test]
213    fn touch_synth_move_does_not_open_submenu() {
214        // Root cause of "tapping a submenu parent activates its first child on
215        // mobile": a touch tap synthesises a MouseMove at the tap point before
216        // the MouseDown.  `update_hover` used to OPEN the submenu (`open_path`)
217        // on that move; the follow-up MouseDown then hit-tested the freshly
218        // opened submenu and — on a narrow viewport where the submenu overlaps
219        // its parent — landed on (and activated) the first child.  On touch,
220        // submenus must open only on the explicit tap in `handle_left_down`,
221        // never on the synth move.
222        let items = test_items();
223        let mut state = PopupMenuState::default();
224        state.open_at(Point::new(20.0, 200.0), MenuAnchorKind::Context);
225        let viewport = Size::new(400.0, 300.0);
226        // Row 3 is the "More" submenu parent in `test_items`.
227        let more_row = state.layouts(&items, viewport)[0].rows[3].rect;
228
229        crate::touch_state::clear_last_touch_event_for_testing();
230        crate::touch_state::note_touch_event();
231
232        state.update_hover(
233            &items,
234            Point::new(more_row.x + 10.0, more_row.y + more_row.height * 0.5),
235            viewport,
236        );
237        assert!(
238            state.open_path.is_empty(),
239            "a touch-synth MouseMove must not open a submenu (open_path = {:?})",
240            state.open_path
241        );
242
243        crate::touch_state::clear_last_touch_event_for_testing();
244    }
245
246    #[test]
247    fn touch_tap_on_submenu_parent_opens_it_without_activating_child() {
248        // End-to-end symptom: on a NARROW viewport the "More" submenu clamps
249        // back over its parent, so the first child lands under the finger.  A
250        // touch tap (synth MouseMove → MouseDown at the same point) on the
251        // submenu parent must OPEN the submenu and must NOT activate a child.
252        let mut items = test_items();
253        let mut state = PopupMenuState::default();
254        // Narrow enough that the submenu (MENU_W = 224) can't sit beside the
255        // parent and gets clamped over it.
256        let viewport = Size::new(300.0, 320.0);
257        state.open_at(Point::new(20.0, 250.0), MenuAnchorKind::Context);
258        let more_row = state.layouts(&items, viewport)[0].rows[3].rect;
259        // Tap toward the right of the parent row, where the clamped submenu's
260        // first child overlaps.
261        let tap = Point::new(
262            more_row.x + more_row.width - 20.0,
263            more_row.y + more_row.height * 0.5,
264        );
265
266        crate::touch_state::clear_last_touch_event_for_testing();
267        crate::touch_state::note_touch_event();
268
269        // Synth MouseMove (touchstart), then MouseDown at the same point.
270        state.handle_event(&mut items, &Event::MouseMove { pos: tap }, viewport);
271        let (_, response) = state.handle_event(
272            &mut items,
273            &Event::MouseDown {
274                pos: tap,
275                button: MouseButton::Left,
276                modifiers: Modifiers::default(),
277            },
278            viewport,
279        );
280
281        assert!(
282            !matches!(response, MenuResponse::Action(_)),
283            "tapping a submenu parent must not activate a child (got {response:?})"
284        );
285        assert_eq!(
286            state.open_path,
287            vec![3],
288            "tapping the submenu parent should open its submenu"
289        );
290
291        crate::touch_state::clear_last_touch_event_for_testing();
292    }
293
294    #[test]
295    fn disabled_rows_do_not_become_hovered() {
296        let items = test_items();
297        let mut state = PopupMenuState::default();
298        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
299        let viewport = Size::new(400.0, 240.0);
300        let disabled_row = state.layouts(&items, viewport)[0].rows[1].rect;
301
302        assert!(!state.update_hover(
303            &items,
304            Point::new(disabled_row.x + 10.0, disabled_row.y + 10.0),
305            viewport,
306        ));
307        assert_eq!(state.hover_path, None);
308    }
309
310    #[test]
311    fn touch_synthesized_move_does_not_set_popup_hover() {
312        // Regression: a touch tap synthesises a MouseMove at the tap point
313        // before the MouseDown.  Without suppression, that move would set
314        // `hover_path` and the post-tap state would still paint a hover
315        // panel on the just-tapped row even though the menu has closed.
316        // After the fix, an enabled-row MouseMove inside the touch-synth
317        // window must leave `hover_path` as `None`.
318        let items = test_items();
319        let mut state = PopupMenuState::default();
320        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
321        let viewport = Size::new(400.0, 240.0);
322        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
323
324        // Force the touch-synth window to be active by recording a touch
325        // event right before the MouseMove — same call the touch shells
326        // make on every touchstart / touchmove / touchend.
327        crate::touch_state::clear_last_touch_event_for_testing();
328        crate::touch_state::note_touch_event();
329
330        state.update_hover(
331            &items,
332            Point::new(first_row.x + 10.0, first_row.y + 10.0),
333            viewport,
334        );
335        assert_eq!(
336            state.hover_path, None,
337            "a touch-synth MouseMove must not paint a popup-row hover"
338        );
339
340        // Reset for sibling tests.
341        crate::touch_state::clear_last_touch_event_for_testing();
342    }
343
344    #[test]
345    fn desktop_move_still_sets_popup_hover() {
346        // Mirror test: outside the touch-synth window the same MouseMove
347        // SHOULD set hover so desktop users see the subtle hover panel.
348        let items = test_items();
349        let mut state = PopupMenuState::default();
350        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
351        let viewport = Size::new(400.0, 240.0);
352        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
353
354        crate::touch_state::clear_last_touch_event_for_testing();
355
356        assert!(state.update_hover(
357            &items,
358            Point::new(first_row.x + 10.0, first_row.y + 10.0),
359            viewport,
360        ));
361        assert_eq!(state.hover_path, Some(vec![0]));
362    }
363
364    #[test]
365    fn outside_click_dismisses_menu() {
366        let mut items = test_items();
367        let mut state = PopupMenuState::default();
368        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
369        let (_, response) = state.handle_event(
370            &mut items,
371            &Event::MouseDown {
372                pos: Point::new(390.0, 10.0),
373                button: MouseButton::Left,
374                modifiers: Modifiers::default(),
375            },
376            Size::new(400.0, 240.0),
377        );
378        assert_eq!(response, MenuResponse::Closed);
379        assert!(!state.open);
380    }
381
382    #[test]
383    fn keyboard_navigation_activates_hovered_row() {
384        let mut items = test_items();
385        let mut state = PopupMenuState::default();
386        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
387        let viewport = Size::new(400.0, 240.0);
388
389        state.handle_event(
390            &mut items,
391            &Event::KeyDown {
392                key: Key::ArrowDown,
393                modifiers: Modifiers::default(),
394            },
395            viewport,
396        );
397        let (_, response) = state.handle_event(
398            &mut items,
399            &Event::KeyDown {
400                key: Key::Enter,
401                modifiers: Modifiers::default(),
402            },
403            viewport,
404        );
405        assert_eq!(response, MenuResponse::Action("open".to_string()));
406    }
407
408    #[test]
409    fn submenu_chevron_renders_as_vector_triangle_pointing_right() {
410        // Regression: the chevron used to be painted via `fill_text`
411        // with U+25B8.  Hosts whose font (and any icon fallback) lacked
412        // that code point — e.g. AtomArtist's NotoSans + Bootstrap Icons
413        // stack — drew an empty tofu box instead of the indicator.  The
414        // chevron is now a pure vector polyline so it renders correctly
415        // regardless of the host's font configuration.
416        let row = Rect::new(100.0, 50.0, 200.0, 24.0);
417        let [top, apex, bottom] = submenu_chevron_points(row);
418
419        // Apex points to the right of the two arms.
420        assert!(apex.0 > top.0);
421        assert!(apex.0 > bottom.0);
422
423        // Top and bottom arms share an x and straddle the apex's y.
424        assert!((top.0 - bottom.0).abs() < f64::EPSILON);
425        let mid_y = row.y + row.height * 0.5;
426        assert!((top.1 - mid_y - (mid_y - bottom.1)).abs() < f64::EPSILON);
427
428        // Chevron sits within the row's bounds (no leak into the
429        // shortcut column or off the right edge).
430        let right_edge = row.x + row.width;
431        assert!(apex.0 < right_edge);
432        assert!(top.1 <= row.y + row.height);
433        assert!(bottom.1 >= row.y);
434    }
435
436    #[test]
437    fn model_and_style_include_icons_and_shadow() {
438        let items = test_items();
439        let MenuEntry::Item(item) = &items[0] else {
440            panic!("first row should be an item");
441        };
442        assert_eq!(item.icon, Some('\u{f07c}'));
443        assert!(item.shortcut.is_some());
444        assert!(MenuStyle::default().shadow_alpha > 0.0);
445    }
446}