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 outside_click_dismisses_menu() {
226 let mut items = test_items();
227 let mut state = PopupMenuState::default();
228 state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
229 let (_, response) = state.handle_event(
230 &mut items,
231 &Event::MouseDown {
232 pos: Point::new(390.0, 10.0),
233 button: MouseButton::Left,
234 modifiers: Modifiers::default(),
235 },
236 Size::new(400.0, 240.0),
237 );
238 assert_eq!(response, MenuResponse::Closed);
239 assert!(!state.open);
240 }
241
242 #[test]
243 fn keyboard_navigation_activates_hovered_row() {
244 let mut items = test_items();
245 let mut state = PopupMenuState::default();
246 state.open_at(Point::new(20.0, 160.0), MenuAnchorKind::Context);
247 let viewport = Size::new(400.0, 240.0);
248
249 state.handle_event(
250 &mut items,
251 &Event::KeyDown {
252 key: Key::ArrowDown,
253 modifiers: Modifiers::default(),
254 },
255 viewport,
256 );
257 let (_, response) = state.handle_event(
258 &mut items,
259 &Event::KeyDown {
260 key: Key::Enter,
261 modifiers: Modifiers::default(),
262 },
263 viewport,
264 );
265 assert_eq!(response, MenuResponse::Action("open".to_string()));
266 }
267
268 #[test]
269 fn model_and_style_include_icons_and_shadow() {
270 let items = test_items();
271 let MenuEntry::Item(item) = &items[0] else {
272 panic!("first row should be an item");
273 };
274 assert_eq!(item.icon, Some('\u{f07c}'));
275 assert!(item.shortcut.is_some());
276 assert!(MenuStyle::default().shadow_alpha > 0.0);
277 }
278}