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(more_row.x + more_row.width - 20.0, more_row.y + more_row.height * 0.5);
262
263        crate::touch_state::clear_last_touch_event_for_testing();
264        crate::touch_state::note_touch_event();
265
266        // Synth MouseMove (touchstart), then MouseDown at the same point.
267        state.handle_event(&mut items, &Event::MouseMove { pos: tap }, viewport);
268        let (_, response) = state.handle_event(
269            &mut items,
270            &Event::MouseDown {
271                pos: tap,
272                button: MouseButton::Left,
273                modifiers: Modifiers::default(),
274            },
275            viewport,
276        );
277
278        assert!(
279            !matches!(response, MenuResponse::Action(_)),
280            "tapping a submenu parent must not activate a child (got {response:?})"
281        );
282        assert_eq!(
283            state.open_path,
284            vec![3],
285            "tapping the submenu parent should open its submenu"
286        );
287
288        crate::touch_state::clear_last_touch_event_for_testing();
289    }
290
291    #[test]
292    fn disabled_rows_do_not_become_hovered() {
293        let items = test_items();
294        let mut state = PopupMenuState::default();
295        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
296        let viewport = Size::new(400.0, 240.0);
297        let disabled_row = state.layouts(&items, viewport)[0].rows[1].rect;
298
299        assert!(!state.update_hover(
300            &items,
301            Point::new(disabled_row.x + 10.0, disabled_row.y + 10.0),
302            viewport,
303        ));
304        assert_eq!(state.hover_path, None);
305    }
306
307    #[test]
308    fn touch_synthesized_move_does_not_set_popup_hover() {
309        // Regression: a touch tap synthesises a MouseMove at the tap point
310        // before the MouseDown.  Without suppression, that move would set
311        // `hover_path` and the post-tap state would still paint a hover
312        // panel on the just-tapped row even though the menu has closed.
313        // After the fix, an enabled-row MouseMove inside the touch-synth
314        // window must leave `hover_path` as `None`.
315        let items = test_items();
316        let mut state = PopupMenuState::default();
317        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
318        let viewport = Size::new(400.0, 240.0);
319        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
320
321        // Force the touch-synth window to be active by recording a touch
322        // event right before the MouseMove — same call the touch shells
323        // make on every touchstart / touchmove / touchend.
324        crate::touch_state::clear_last_touch_event_for_testing();
325        crate::touch_state::note_touch_event();
326
327        state.update_hover(
328            &items,
329            Point::new(first_row.x + 10.0, first_row.y + 10.0),
330            viewport,
331        );
332        assert_eq!(
333            state.hover_path, None,
334            "a touch-synth MouseMove must not paint a popup-row hover"
335        );
336
337        // Reset for sibling tests.
338        crate::touch_state::clear_last_touch_event_for_testing();
339    }
340
341    #[test]
342    fn desktop_move_still_sets_popup_hover() {
343        // Mirror test: outside the touch-synth window the same MouseMove
344        // SHOULD set hover so desktop users see the subtle hover panel.
345        let items = test_items();
346        let mut state = PopupMenuState::default();
347        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
348        let viewport = Size::new(400.0, 240.0);
349        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
350
351        crate::touch_state::clear_last_touch_event_for_testing();
352
353        assert!(state.update_hover(
354            &items,
355            Point::new(first_row.x + 10.0, first_row.y + 10.0),
356            viewport,
357        ));
358        assert_eq!(state.hover_path, Some(vec![0]));
359    }
360
361    #[test]
362    fn outside_click_dismisses_menu() {
363        let mut items = test_items();
364        let mut state = PopupMenuState::default();
365        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
366        let (_, response) = state.handle_event(
367            &mut items,
368            &Event::MouseDown {
369                pos: Point::new(390.0, 10.0),
370                button: MouseButton::Left,
371                modifiers: Modifiers::default(),
372            },
373            Size::new(400.0, 240.0),
374        );
375        assert_eq!(response, MenuResponse::Closed);
376        assert!(!state.open);
377    }
378
379    #[test]
380    fn keyboard_navigation_activates_hovered_row() {
381        let mut items = test_items();
382        let mut state = PopupMenuState::default();
383        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
384        let viewport = Size::new(400.0, 240.0);
385
386        state.handle_event(
387            &mut items,
388            &Event::KeyDown {
389                key: Key::ArrowDown,
390                modifiers: Modifiers::default(),
391            },
392            viewport,
393        );
394        let (_, response) = state.handle_event(
395            &mut items,
396            &Event::KeyDown {
397                key: Key::Enter,
398                modifiers: Modifiers::default(),
399            },
400            viewport,
401        );
402        assert_eq!(response, MenuResponse::Action("open".to_string()));
403    }
404
405    #[test]
406    fn submenu_chevron_renders_as_vector_triangle_pointing_right() {
407        // Regression: the chevron used to be painted via `fill_text`
408        // with U+25B8.  Hosts whose font (and any icon fallback) lacked
409        // that code point — e.g. AtomArtist's NotoSans + Bootstrap Icons
410        // stack — drew an empty tofu box instead of the indicator.  The
411        // chevron is now a pure vector polyline so it renders correctly
412        // regardless of the host's font configuration.
413        let row = Rect::new(100.0, 50.0, 200.0, 24.0);
414        let [top, apex, bottom] = submenu_chevron_points(row);
415
416        // Apex points to the right of the two arms.
417        assert!(apex.0 > top.0);
418        assert!(apex.0 > bottom.0);
419
420        // Top and bottom arms share an x and straddle the apex's y.
421        assert!((top.0 - bottom.0).abs() < f64::EPSILON);
422        let mid_y = row.y + row.height * 0.5;
423        assert!((top.1 - mid_y - (mid_y - bottom.1)).abs() < f64::EPSILON);
424
425        // Chevron sits within the row's bounds (no leak into the
426        // shortcut column or off the right edge).
427        let right_edge = row.x + row.width;
428        assert!(apex.0 < right_edge);
429        assert!(top.1 <= row.y + row.height);
430        assert!(bottom.1 >= row.y);
431    }
432
433    #[test]
434    fn model_and_style_include_icons_and_shadow() {
435        let items = test_items();
436        let MenuEntry::Item(item) = &items[0] else {
437            panic!("first row should be an item");
438        };
439        assert_eq!(item.icon, Some('\u{f07c}'));
440        assert!(item.shortcut.is_some());
441        assert!(MenuStyle::default().shadow_alpha > 0.0);
442    }
443}