1use std::sync::Arc;
7
8use crate::draw_ctx::DrawCtx;
9use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
10use crate::geometry::{Point, Rect, Size};
11use crate::text::Font;
12use crate::widget::{current_viewport, Widget};
13
14use super::geometry::{contains, item_at_path, BAR_H};
15use super::model::MenuEntry;
16use super::paint::{paint_menu_bar_button, paint_popup_stack, MenuStyle};
17use super::state::{MenuAnchorKind, MenuResponse, PopupMenuState};
18
19#[derive(Clone)]
20pub struct PopupMenu {
21 pub items: Vec<MenuEntry>,
22 pub state: PopupMenuState,
23 pub style: MenuStyle,
24}
25
26impl PopupMenu {
27 pub fn new(items: Vec<MenuEntry>) -> Self {
28 Self {
29 items,
30 state: PopupMenuState::default(),
31 style: MenuStyle::default(),
32 }
33 }
34
35 pub fn open_at(&mut self, pos: Point) {
36 self.state.open_at(pos, MenuAnchorKind::Context);
37 }
38
39 pub fn close(&mut self) {
40 self.state.close();
41 }
42
43 pub fn is_open(&self) -> bool {
44 self.state.open
45 }
46
47 pub fn take_suppress_mouse_up(&mut self) -> bool {
48 self.state.take_suppress_mouse_up()
49 }
50
51 pub fn handle_event(&mut self, event: &Event, viewport: Size) -> (EventResult, MenuResponse) {
52 self.state.handle_event(&mut self.items, event, viewport)
53 }
54
55 pub fn handle_shortcut(&mut self, key: &Key, modifiers: Modifiers) -> MenuResponse {
56 self.state.handle_shortcut(&mut self.items, key, modifiers)
57 }
58
59 pub fn paint(&self, ctx: &mut dyn DrawCtx, font: Arc<Font>, font_size: f64, viewport: Size) {
60 let layouts = self.state.layouts(&self.items, viewport);
61 paint_popup_stack(
62 ctx,
63 font,
64 font_size,
65 &self.items,
66 &self.state,
67 &layouts,
68 &self.style,
69 );
70 }
71}
72
73pub struct MenuBar {
74 bounds: Rect,
75 children: Vec<Box<dyn Widget>>,
76 font: Arc<Font>,
77 font_size: f64,
78 menus: Vec<TopMenu>,
79 open_index: Option<usize>,
80 hover_index: Option<usize>,
81 popup: PopupMenu,
82 on_action: Box<dyn FnMut(&str)>,
83}
84
85pub struct TopMenu {
86 pub label: String,
87 pub items: Vec<MenuEntry>,
88 rect: Rect,
89}
90
91impl TopMenu {
92 pub fn new(label: impl Into<String>, items: Vec<MenuEntry>) -> Self {
93 Self {
94 label: label.into(),
95 items,
96 rect: Rect::default(),
97 }
98 }
99}
100
101impl MenuBar {
102 pub fn new(
103 font: Arc<Font>,
104 menus: Vec<TopMenu>,
105 on_action: impl FnMut(&str) + 'static,
106 ) -> Self {
107 Self {
108 bounds: Rect::default(),
109 children: Vec::new(),
110 font,
111 font_size: 14.0,
112 menus,
113 open_index: None,
114 hover_index: None,
115 popup: PopupMenu::new(Vec::new()),
116 on_action: Box::new(on_action),
117 }
118 }
119
120 pub fn with_font_size(mut self, font_size: f64) -> Self {
121 self.font_size = font_size;
122 self
123 }
124
125 fn menu_at(&self, pos: Point) -> Option<usize> {
126 self.menus.iter().position(|menu| contains(menu.rect, pos))
127 }
128
129 fn open_menu(&mut self, idx: usize) {
130 let rect = self.menus[idx].rect;
131 self.popup.items = self.menus[idx].items.clone();
132 self.popup
133 .state
134 .open_at(Point::new(rect.x, rect.y), MenuAnchorKind::Bar);
135 self.open_index = Some(idx);
136 self.hover_index = Some(idx);
137 crate::animation::request_draw();
138 }
139
140 fn open_menu_for_drag_release(&mut self, idx: usize) {
141 self.open_menu(idx);
142 self.popup.state.arm_mouse_up_activation();
143 }
144
145 fn switch_open_menu(&mut self, delta: isize) -> EventResult {
146 let Some(current) = self.open_index else {
147 return EventResult::Ignored;
148 };
149 if self.menus.is_empty() {
150 return EventResult::Ignored;
151 }
152 let len = self.menus.len() as isize;
153 let next = (current as isize + delta).rem_euclid(len) as usize;
154 self.open_menu(next);
155 EventResult::Consumed
156 }
157
158 fn should_switch_top_menu(&self, key: &Key) -> bool {
159 match key {
160 Key::ArrowLeft => self.popup.state.open_path.is_empty(),
161 Key::ArrowRight => {
162 if !self.popup.state.open_path.is_empty() {
163 return false;
164 }
165 self.popup
166 .state
167 .hover_path
168 .as_deref()
169 .and_then(|path| item_at_path(&self.popup.items, path))
170 .map_or(true, |item| !item.has_submenu())
171 }
172 _ => false,
173 }
174 }
175
176 fn set_hover_index(&mut self, hover: Option<usize>) {
177 if self.hover_index != hover {
178 self.hover_index = hover;
179 crate::animation::request_draw_without_invalidation();
180 }
181 }
182}
183
184impl Widget for MenuBar {
185 fn type_name(&self) -> &'static str {
186 "MenuBar"
187 }
188
189 fn bounds(&self) -> Rect {
190 self.bounds
191 }
192
193 fn set_bounds(&mut self, bounds: Rect) {
194 self.bounds = bounds;
195 }
196
197 fn children(&self) -> &[Box<dyn Widget>] {
198 &self.children
199 }
200
201 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
202 &mut self.children
203 }
204
205 fn layout(&mut self, available: Size) -> Size {
206 let mut x = 0.0;
207 for menu in &mut self.menus {
208 let width = (menu.label.chars().count() as f64 * 8.0 + 22.0).max(52.0);
209 menu.rect = Rect::new(x, 0.0, width, BAR_H);
210 x += width;
211 }
212 Size::new(available.width, BAR_H)
213 }
214
215 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
216 ctx.set_font(Arc::clone(&self.font));
217 ctx.set_font_size(self.font_size);
218 let v = ctx.visuals();
219 ctx.set_fill_color(v.top_bar_bg);
220 ctx.begin_path();
221 ctx.rect(0.0, 0.0, self.bounds.width, BAR_H);
222 ctx.fill();
223 for (idx, menu) in self.menus.iter().enumerate() {
224 paint_menu_bar_button(
225 ctx,
226 menu.rect,
227 &menu.label,
228 self.open_index == Some(idx),
229 self.hover_index == Some(idx),
230 );
231 }
232 }
233
234 fn hit_test_global_overlay(&self, _local_pos: Point) -> bool {
235 self.popup.is_open()
236 }
237
238 fn has_active_modal(&self) -> bool {
239 self.popup.is_open()
240 }
241
242 fn on_event(&mut self, event: &Event) -> EventResult {
243 if let Event::MouseMove { pos } = event {
244 let hovered = self.menu_at(*pos);
245 self.set_hover_index(hovered);
246 if self.popup.is_open() {
247 if let Some(idx) = hovered {
248 if self.open_index != Some(idx) {
249 let activate_on_release = self.popup.state.is_mouse_up_activation_armed();
250 self.open_menu(idx);
251 if activate_on_release {
252 self.popup.state.arm_mouse_up_activation();
253 }
254 }
255 return EventResult::Consumed;
256 }
257 }
258 }
259 if self.popup.is_open() {
260 if let Event::KeyDown { key, .. } = event {
261 if self.should_switch_top_menu(key) {
262 return match key {
263 Key::ArrowLeft => self.switch_open_menu(-1),
264 Key::ArrowRight => self.switch_open_menu(1),
265 _ => EventResult::Ignored,
266 };
267 }
268 }
269 let (result, response) = self.popup.handle_event(event, current_viewport());
270 if let MenuResponse::Action(action) = response {
271 if let Some(idx) = self.open_index {
272 self.menus[idx].items = self.popup.items.clone();
273 }
274 (self.on_action)(&action);
275 if !self.popup.is_open() {
276 self.open_index = None;
277 }
278 } else if matches!(response, MenuResponse::Closed) {
279 self.open_index = None;
280 }
281 if result == EventResult::Consumed {
282 return result;
283 }
284 }
285 match event {
286 Event::MouseDown {
287 pos,
288 button: MouseButton::Left,
289 ..
290 } => {
291 if let Some(idx) = self.menu_at(*pos) {
292 self.open_menu_for_drag_release(idx);
293 EventResult::Consumed
294 } else {
295 EventResult::Ignored
296 }
297 }
298 Event::MouseMove { .. } => EventResult::Ignored,
299 _ => EventResult::Ignored,
300 }
301 }
302
303 fn on_unconsumed_key(&mut self, key: &Key, modifiers: Modifiers) -> EventResult {
304 let response = if self.popup.is_open() {
305 self.popup.handle_shortcut(key, modifiers)
306 } else {
307 self.menus
308 .iter_mut()
309 .find_map(|menu| {
310 let mut popup = PopupMenu::new(menu.items.clone());
311 match popup.handle_shortcut(key, modifiers) {
312 MenuResponse::Action(action) => {
313 menu.items = popup.items;
314 Some(action)
315 }
316 MenuResponse::None | MenuResponse::Closed => None,
317 }
318 })
319 .map(MenuResponse::Action)
320 .unwrap_or(MenuResponse::None)
321 };
322 if let MenuResponse::Action(action) = response {
323 if let Some(idx) = self.open_index {
324 self.menus[idx].items = self.popup.items.clone();
325 }
326 (self.on_action)(&action);
327 if !self.popup.is_open() {
328 self.open_index = None;
329 }
330 EventResult::Consumed
331 } else {
332 EventResult::Ignored
333 }
334 }
335
336 fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
337 self.popup.paint(
338 ctx,
339 Arc::clone(&self.font),
340 self.font_size,
341 current_viewport(),
342 );
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::event::{Modifiers, MouseButton};
350 use std::cell::RefCell;
351 use std::rc::Rc;
352
353 fn test_font() -> Arc<Font> {
354 const FONT_BYTES: &[u8] = include_bytes!("../../../../demo/assets/CascadiaCode.ttf");
355 Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
356 }
357
358 #[test]
359 fn moving_across_top_menus_switches_open_popup() {
360 let mut bar = MenuBar::new(
361 test_font(),
362 vec![
363 TopMenu::new(
364 "File",
365 vec![super::super::model::MenuItem::action("New", "file.new").into()],
366 ),
367 TopMenu::new(
368 "Edit",
369 vec![super::super::model::MenuItem::action("Copy", "edit.copy").into()],
370 ),
371 ],
372 |_| {},
373 );
374 bar.layout(Size::new(300.0, BAR_H));
375
376 assert_eq!(
377 bar.on_event(&Event::MouseDown {
378 pos: Point::new(8.0, 8.0),
379 button: MouseButton::Left,
380 modifiers: Modifiers::default(),
381 }),
382 EventResult::Consumed
383 );
384 assert_eq!(bar.open_index, Some(0));
385
386 assert_eq!(
387 bar.on_event(&Event::MouseMove {
388 pos: Point::new(60.0, 8.0),
389 }),
390 EventResult::Consumed
391 );
392 assert_eq!(bar.open_index, Some(1));
393 let Some(super::super::model::MenuEntry::Item(item)) = bar.popup.items.first() else {
394 panic!("popup should contain Edit items");
395 };
396 assert_eq!(item.action.as_deref(), Some("edit.copy"));
397 }
398
399 #[test]
400 fn top_level_menu_tracks_hover() {
401 let mut bar = MenuBar::new(
402 test_font(),
403 vec![TopMenu::new(
404 "File",
405 vec![super::super::model::MenuItem::action("New", "file.new").into()],
406 )],
407 |_| {},
408 );
409 bar.layout(Size::new(300.0, BAR_H));
410
411 assert_eq!(
412 bar.on_event(&Event::MouseMove {
413 pos: Point::new(8.0, 8.0),
414 }),
415 EventResult::Ignored
416 );
417 assert_eq!(bar.hover_index, Some(0));
418 }
419
420 #[test]
421 fn mouse_down_drag_release_activates_popup_item() {
422 let viewport = Size::new(300.0, 180.0);
423 crate::widget::set_current_viewport(viewport);
424 let actions = Rc::new(RefCell::new(Vec::new()));
425 let actions_for_cb = Rc::clone(&actions);
426 let mut bar = MenuBar::new(
427 test_font(),
428 vec![TopMenu::new(
429 "File",
430 vec![super::super::model::MenuItem::action("New", "file.new").into()],
431 )],
432 move |action| actions_for_cb.borrow_mut().push(action.to_string()),
433 );
434 bar.layout(Size::new(300.0, BAR_H));
435
436 assert_eq!(
437 bar.on_event(&Event::MouseDown {
438 pos: Point::new(8.0, 8.0),
439 button: MouseButton::Left,
440 modifiers: Modifiers::default(),
441 }),
442 EventResult::Consumed
443 );
444 let row = bar.popup.state.layouts(&bar.popup.items, viewport)[0].rows[0].rect;
445 let item_pos = Point::new(row.x + 12.0, row.y + 12.0);
446
447 assert_eq!(
448 bar.on_event(&Event::MouseMove { pos: item_pos }),
449 EventResult::Consumed
450 );
451 assert_eq!(
452 bar.on_event(&Event::MouseUp {
453 pos: item_pos,
454 button: MouseButton::Left,
455 modifiers: Modifiers::default(),
456 }),
457 EventResult::Consumed
458 );
459
460 assert_eq!(actions.borrow().as_slice(), ["file.new"]);
461 assert!(!bar.popup.is_open());
462 }
463
464 #[test]
465 fn simple_mouse_click_opens_menu_without_release_activation() {
466 let viewport = Size::new(300.0, 180.0);
467 crate::widget::set_current_viewport(viewport);
468 let mut bar = MenuBar::new(
469 test_font(),
470 vec![TopMenu::new(
471 "File",
472 vec![super::super::model::MenuItem::action("New", "file.new").into()],
473 )],
474 |_| {},
475 );
476 bar.layout(Size::new(300.0, BAR_H));
477
478 assert_eq!(
479 bar.on_event(&Event::MouseDown {
480 pos: Point::new(8.0, 8.0),
481 button: MouseButton::Left,
482 modifiers: Modifiers::default(),
483 }),
484 EventResult::Consumed
485 );
486 assert_eq!(
487 bar.on_event(&Event::MouseUp {
488 pos: Point::new(8.0, 8.0),
489 button: MouseButton::Left,
490 modifiers: Modifiers::default(),
491 }),
492 EventResult::Consumed
493 );
494
495 assert!(bar.popup.is_open());
496 assert_eq!(bar.open_index, Some(0));
497 }
498
499 #[test]
500 fn unconsumed_shortcut_fires_top_menu_action() {
501 let actions = Rc::new(RefCell::new(Vec::new()));
502 let actions_for_cb = Rc::clone(&actions);
503 let mut bar = MenuBar::new(
504 test_font(),
505 vec![TopMenu::new(
506 "File",
507 vec![super::super::model::MenuItem::action("New", "file.new")
508 .shortcut("Ctrl+N")
509 .into()],
510 )],
511 move |action| actions_for_cb.borrow_mut().push(action.to_string()),
512 );
513
514 assert_eq!(
515 bar.on_unconsumed_key(
516 &Key::Char('n'),
517 Modifiers {
518 ctrl: true,
519 ..Modifiers::default()
520 },
521 ),
522 EventResult::Consumed
523 );
524
525 assert_eq!(actions.borrow().as_slice(), ["file.new"]);
526 }
527
528 #[test]
529 fn arrow_keys_switch_open_top_menus() {
530 let mut bar = MenuBar::new(
531 test_font(),
532 vec![
533 TopMenu::new(
534 "File",
535 vec![super::super::model::MenuItem::action("New", "file.new").into()],
536 ),
537 TopMenu::new(
538 "Edit",
539 vec![super::super::model::MenuItem::action("Copy", "edit.copy").into()],
540 ),
541 ],
542 |_| {},
543 );
544 bar.layout(Size::new(300.0, BAR_H));
545 bar.open_menu(0);
546
547 assert_eq!(
548 bar.on_event(&Event::KeyDown {
549 key: Key::ArrowRight,
550 modifiers: Modifiers::default(),
551 }),
552 EventResult::Consumed
553 );
554 assert_eq!(bar.open_index, Some(1));
555
556 assert_eq!(
557 bar.on_event(&Event::KeyDown {
558 key: Key::ArrowLeft,
559 modifiers: Modifiers::default(),
560 }),
561 EventResult::Consumed
562 );
563 assert_eq!(bar.open_index, Some(0));
564 }
565}