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