1pub 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 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 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 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 reset_env();
284 let mut items = test_items();
285 let mut state = PopupMenuState::default();
286 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 let more_row = state.layouts(&items, viewport)[0].rows[3].rect;
295 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 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 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 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 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 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 let row = Rect::new(100.0, 50.0, 200.0, 24.0);
460 let [top, apex, bottom] = submenu_chevron_points(row);
461
462 assert!(apex.0 > top.0);
464 assert!(apex.0 > bottom.0);
465
466 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 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}