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 widget;
11
12pub use geometry::{BAR_H as MENU_BAR_H, MENU_W, ROW_H};
13pub use model::{MenuEntry, MenuItem, MenuSelection, MenuShortcut, ShortcutKey};
14pub use paint::MenuStyle;
15pub use state::{MenuAnchorKind, MenuResponse, PopupMenuState};
16pub use widget::{MenuBar, PopupMenu, TopMenu};
17
18#[cfg(test)]
19mod tests {
20    use crate::event::{Event, Key, Modifiers, MouseButton};
21    use crate::geometry::{Point, Size};
22
23    use super::geometry::{hit_test, stack_layout, MenuHit};
24    use super::*;
25
26    fn test_items() -> Vec<MenuEntry> {
27        vec![
28            MenuItem::action("Open", "open")
29                .icon('\u{f07c}')
30                .shortcut("Ctrl+O")
31                .into(),
32            MenuItem::action("Disabled", "disabled").disabled().into(),
33            MenuEntry::Separator,
34            MenuItem::submenu(
35                "More",
36                vec![
37                    MenuItem::action("Leaf", "leaf").into(),
38                    MenuItem::action("Checked", "checked").checked(true).into(),
39                ],
40            )
41            .into(),
42        ]
43    }
44
45    #[test]
46    fn popup_clamps_to_viewport() {
47        let items = test_items();
48        let layouts = stack_layout(
49            &items,
50            Point::new(500.0, -50.0),
51            MenuAnchorKind::Context,
52            &[],
53            Size::new(240.0, 120.0),
54        );
55        let rect = layouts[0].rect;
56        assert!(rect.x >= 4.0);
57        assert!(rect.y >= 4.0);
58        assert!(rect.x + rect.width <= 240.0);
59        assert!(rect.y + rect.height <= 120.0);
60    }
61
62    #[test]
63    fn menu_bar_popups_can_open_below_the_bar() {
64        let items = test_items();
65        let layouts = stack_layout(
66            &items,
67            Point::new(20.0, 0.0),
68            MenuAnchorKind::Bar,
69            &[],
70            Size::new(400.0, 240.0),
71        );
72        assert!(
73            layouts[0].rect.y < 0.0,
74            "bar popups use negative local Y so they paint below a top menu bar"
75        );
76    }
77
78    #[test]
79    fn hover_opens_submenu_and_hit_tests_nested_rows() {
80        let items = test_items();
81        let mut state = PopupMenuState::default();
82        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
83        let viewport = Size::new(400.0, 240.0);
84        let layouts = state.layouts(&items, viewport);
85        let more_row = layouts[0].rows[3].rect;
86
87        assert!(state.update_hover(
88            &items,
89            Point::new(more_row.x + 10.0, more_row.y + 10.0),
90            viewport
91        ));
92        assert_eq!(state.open_path, vec![3]);
93
94        let layouts = state.layouts(&items, viewport);
95        let submenu_row = layouts[1].rows[0].rect;
96        assert!(matches!(
97            hit_test(
98                &layouts,
99                Point::new(submenu_row.x + 10.0, submenu_row.y + 10.0)
100            ),
101            Some(MenuHit::Item(path)) if path == vec![3, 0]
102        ));
103    }
104
105    #[test]
106    fn action_click_consumes_and_suppresses_followup_mouse_up() {
107        let mut items = test_items();
108        let mut state = PopupMenuState::default();
109        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
110        let viewport = Size::new(400.0, 240.0);
111        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
112
113        let (_, response) = state.handle_event(
114            &mut items,
115            &Event::MouseDown {
116                pos: Point::new(first_row.x + 10.0, first_row.y + 10.0),
117                button: MouseButton::Left,
118                modifiers: Modifiers::default(),
119            },
120            viewport,
121        );
122        assert_eq!(response, MenuResponse::Action("open".to_string()));
123        assert!(state.take_suppress_mouse_up());
124    }
125
126    #[test]
127    fn keep_open_check_and_radio_actions_do_not_close() {
128        let mut items = vec![
129            MenuItem::action("Check", "check")
130                .checked(false)
131                .keep_open()
132                .into(),
133            MenuItem::action("Radio A", "radio-a")
134                .radio(true)
135                .keep_open()
136                .into(),
137            MenuItem::action("Radio B", "radio-b")
138                .radio(false)
139                .keep_open()
140                .into(),
141        ];
142        let mut state = PopupMenuState::default();
143        state.open_at(Point::new(20.0, 120.0), MenuAnchorKind::Context);
144        let viewport = Size::new(300.0, 200.0);
145        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
146
147        let (_, response) = state.handle_event(
148            &mut items,
149            &Event::MouseDown {
150                pos: Point::new(first_row.x + 10.0, first_row.y + 10.0),
151                button: MouseButton::Left,
152                modifiers: Modifiers::default(),
153            },
154            viewport,
155        );
156
157        assert_eq!(response, MenuResponse::Action("check".to_string()));
158        assert!(state.open);
159        assert!(!state.should_suppress_mouse_up());
160        let MenuEntry::Item(item) = &items[0] else {
161            panic!("first row should be an item");
162        };
163        assert_eq!(item.selection, MenuSelection::Check { selected: true });
164
165        let third_row = state.layouts(&items, viewport)[0].rows[2].rect;
166        let (_, response) = state.handle_event(
167            &mut items,
168            &Event::MouseDown {
169                pos: Point::new(third_row.x + 10.0, third_row.y + 10.0),
170                button: MouseButton::Left,
171                modifiers: Modifiers::default(),
172            },
173            viewport,
174        );
175        assert_eq!(response, MenuResponse::Action("radio-b".to_string()));
176        assert!(state.open);
177        let MenuEntry::Item(item) = &items[1] else {
178            panic!("second row should be an item");
179        };
180        assert_eq!(item.selection, MenuSelection::Radio { selected: false });
181        let MenuEntry::Item(item) = &items[2] else {
182            panic!("third row should be an item");
183        };
184        assert_eq!(item.selection, MenuSelection::Radio { selected: true });
185    }
186
187    #[test]
188    fn disabled_rows_do_not_fire_actions() {
189        let mut items = test_items();
190        let mut state = PopupMenuState::default();
191        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
192        let viewport = Size::new(400.0, 240.0);
193        let disabled_row = state.layouts(&items, viewport)[0].rows[1].rect;
194
195        let (_, response) = state.handle_event(
196            &mut items,
197            &Event::MouseDown {
198                pos: Point::new(disabled_row.x + 10.0, disabled_row.y + 10.0),
199                button: MouseButton::Left,
200                modifiers: Modifiers::default(),
201            },
202            viewport,
203        );
204        assert_eq!(response, MenuResponse::None);
205        assert!(state.open);
206    }
207
208    #[test]
209    fn disabled_rows_do_not_become_hovered() {
210        let items = test_items();
211        let mut state = PopupMenuState::default();
212        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
213        let viewport = Size::new(400.0, 240.0);
214        let disabled_row = state.layouts(&items, viewport)[0].rows[1].rect;
215
216        assert!(!state.update_hover(
217            &items,
218            Point::new(disabled_row.x + 10.0, disabled_row.y + 10.0),
219            viewport,
220        ));
221        assert_eq!(state.hover_path, None);
222    }
223
224    #[test]
225    fn touch_synthesized_move_does_not_set_popup_hover() {
226        // Regression: a touch tap synthesises a MouseMove at the tap point
227        // before the MouseDown.  Without suppression, that move would set
228        // `hover_path` and the post-tap state would still paint a hover
229        // panel on the just-tapped row even though the menu has closed.
230        // After the fix, an enabled-row MouseMove inside the touch-synth
231        // window must leave `hover_path` as `None`.
232        let items = test_items();
233        let mut state = PopupMenuState::default();
234        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
235        let viewport = Size::new(400.0, 240.0);
236        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
237
238        // Force the touch-synth window to be active by recording a touch
239        // event right before the MouseMove — same call the touch shells
240        // make on every touchstart / touchmove / touchend.
241        crate::touch_state::clear_last_touch_event_for_testing();
242        crate::touch_state::note_touch_event();
243
244        state.update_hover(
245            &items,
246            Point::new(first_row.x + 10.0, first_row.y + 10.0),
247            viewport,
248        );
249        assert_eq!(
250            state.hover_path, None,
251            "a touch-synth MouseMove must not paint a popup-row hover"
252        );
253
254        // Reset for sibling tests.
255        crate::touch_state::clear_last_touch_event_for_testing();
256    }
257
258    #[test]
259    fn desktop_move_still_sets_popup_hover() {
260        // Mirror test: outside the touch-synth window the same MouseMove
261        // SHOULD set hover so desktop users see the subtle hover panel.
262        let items = test_items();
263        let mut state = PopupMenuState::default();
264        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
265        let viewport = Size::new(400.0, 240.0);
266        let first_row = state.layouts(&items, viewport)[0].rows[0].rect;
267
268        crate::touch_state::clear_last_touch_event_for_testing();
269
270        assert!(state.update_hover(
271            &items,
272            Point::new(first_row.x + 10.0, first_row.y + 10.0),
273            viewport,
274        ));
275        assert_eq!(state.hover_path, Some(vec![0]));
276    }
277
278    #[test]
279    fn outside_click_dismisses_menu() {
280        let mut items = test_items();
281        let mut state = PopupMenuState::default();
282        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
283        let (_, response) = state.handle_event(
284            &mut items,
285            &Event::MouseDown {
286                pos: Point::new(390.0, 10.0),
287                button: MouseButton::Left,
288                modifiers: Modifiers::default(),
289            },
290            Size::new(400.0, 240.0),
291        );
292        assert_eq!(response, MenuResponse::Closed);
293        assert!(!state.open);
294    }
295
296    #[test]
297    fn keyboard_navigation_activates_hovered_row() {
298        let mut items = test_items();
299        let mut state = PopupMenuState::default();
300        state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
301        let viewport = Size::new(400.0, 240.0);
302
303        state.handle_event(
304            &mut items,
305            &Event::KeyDown {
306                key: Key::ArrowDown,
307                modifiers: Modifiers::default(),
308            },
309            viewport,
310        );
311        let (_, response) = state.handle_event(
312            &mut items,
313            &Event::KeyDown {
314                key: Key::Enter,
315                modifiers: Modifiers::default(),
316            },
317            viewport,
318        );
319        assert_eq!(response, MenuResponse::Action("open".to_string()));
320    }
321
322    #[test]
323    fn model_and_style_include_icons_and_shadow() {
324        let items = test_items();
325        let MenuEntry::Item(item) = &items[0] else {
326            panic!("first row should be an item");
327        };
328        assert_eq!(item.icon, Some('\u{f07c}'));
329        assert!(item.shortcut.is_some());
330        assert!(MenuStyle::default().shadow_alpha > 0.0);
331    }
332}