1pub 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 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 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 let mut items = test_items();
253 let mut state = PopupMenuState::default();
254 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 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 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 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 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 crate::touch_state::clear_last_touch_event_for_testing();
339 }
340
341 #[test]
342 fn desktop_move_still_sets_popup_hover() {
343 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 let row = Rect::new(100.0, 50.0, 200.0, 24.0);
414 let [top, apex, bottom] = submenu_chevron_points(row);
415
416 assert!(apex.0 > top.0);
418 assert!(apex.0 > bottom.0);
419
420 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 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}