1pub 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 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 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 crate::touch_state::clear_last_touch_event_for_testing();
256 }
257
258 #[test]
259 fn desktop_move_still_sets_popup_hover() {
260 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}