1use std::cell::{Cell, RefCell};
10use std::collections::HashMap;
11use std::rc::Rc;
12
13day_reactive::tls_slots! {
14 menu;
15 static ACTIONS: RefCell<HashMap<u64, Rc<dyn Fn()>>> = RefCell::new(HashMap::new());
16 static NEXT_ID: Cell<u64> = const { Cell::new(1) };
17 static APP_MENU_MODEL: RefCell<Vec<day_spec::MenuItem>> = const { RefCell::new(Vec::new()) };
20 static APP_MENU_IDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
24 static DOCK_MENU_IDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
26 static DOCK_MENU_MODEL: RefCell<Vec<day_spec::MenuItem>> = const { RefCell::new(Vec::new()) };
29 static DOCK_MENU_SHOWN: Cell<bool> = const { Cell::new(false) };
31}
32
33pub fn reset_menus() {
38 ACTIONS.with(|a| a.borrow_mut().clear());
39 APP_MENU_MODEL.with(|m| m.borrow_mut().clear());
40 APP_MENU_IDS.with(|i| i.borrow_mut().clear());
41}
42
43pub fn rebind_action(from: u64, to: u64) {
52 if from == 0 || to == 0 || from == to {
53 return;
54 }
55 ACTIONS.with(|m| {
56 let mut m = m.borrow_mut();
57 if let Some(f) = m.remove(&from) {
58 m.insert(to, f);
59 }
60 });
61}
62
63pub(crate) fn forget_action(id: u64) {
65 ACTIONS.with(|m| {
66 m.borrow_mut().remove(&id);
67 });
68}
69
70pub fn register_menu_action(f: Rc<dyn Fn()>) -> u64 {
71 let id = next_action_id();
72 ACTIONS.with(|m| m.borrow_mut().insert(id, f));
73 id
74}
75
76pub fn register_scoped_menu_action(f: Rc<dyn Fn()>) -> u64 {
86 let id = register_menu_action(f);
87 day_reactive::Scope::current().on_cleanup(move || {
88 ACTIONS.with(|m| {
89 m.borrow_mut().remove(&id);
90 });
91 });
92 id
93}
94
95pub(crate) fn next_action_id() -> u64 {
98 NEXT_ID.with(|c| {
99 let id = c.get();
100 c.set(id.wrapping_add(1).max(1));
101 id
102 })
103}
104
105pub fn dispatch_menu_action(id: u64) {
108 let id = crate::toolbar::resolve_slot(id);
110 let f = ACTIONS.with(|m| m.borrow().get(&id).cloned());
111 if let Some(f) = f {
112 day_reactive::batch(|| f());
113 return;
114 }
115 log::warn!(
120 "menu action {id} has no handler — the build that registered it was disposed and \
121 whatever is still wired to it (an app menu, a context menu, a nav-bar action) was not \
122 updated. The native side needs the NEW id."
123 );
124}
125
126fn same_shape(a: &[day_spec::MenuItem], b: &[day_spec::MenuItem]) -> bool {
133 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| shape_of(x) == shape_of(y))
134}
135
136fn shape_of(item: &day_spec::MenuItem) -> day_spec::MenuItem {
137 let mut i = item.clone();
138 blank_ids(std::slice::from_mut(&mut i));
139 i
140}
141
142fn blank_ids(items: &mut [day_spec::MenuItem]) {
143 for item in items {
144 match item {
145 day_spec::MenuItem::Action { action, .. } => *action = 0,
146 day_spec::MenuItem::Submenu { items, .. } => blank_ids(items),
147 day_spec::MenuItem::Separator => {}
148 }
149 }
150}
151
152fn rebind_menu(next: &mut [day_spec::MenuItem], prev: &[day_spec::MenuItem]) {
154 for (new, old) in next.iter_mut().zip(prev) {
155 match (new, old) {
156 (
157 day_spec::MenuItem::Action { action: new_id, .. },
158 day_spec::MenuItem::Action { action: old_id, .. },
159 ) => {
160 if new_id != old_id {
161 rebind_action(*new_id, *old_id);
162 *new_id = *old_id;
163 }
164 }
165 (
166 day_spec::MenuItem::Submenu { items: n, .. },
167 day_spec::MenuItem::Submenu { items: o, .. },
168 ) => rebind_menu(n, o),
169 _ => {}
170 }
171 }
172}
173
174pub fn set_app_menu(items: Vec<day_spec::MenuItem>) {
175 let items = inject_preferences(items);
176 let mut items = inject_about(items);
177
178 let unchanged = APP_MENU_MODEL.with(|m| same_shape(&m.borrow(), &items));
184 if unchanged {
185 let installed = APP_MENU_MODEL.with(|m| m.borrow().clone());
186 rebind_menu(&mut items, &installed);
187 APP_MENU_MODEL.with(|m| *m.borrow_mut() = items);
188 return;
189 }
190
191 let new_ids = collect_action_ids(&items);
192 let durable = [
195 crate::windows::preferences_action_id(),
196 crate::windows::about_action_id(),
197 crate::windows::check_for_updates_action_id(),
198 crate::windows::new_window_action_id(),
199 crate::undo_action_id(false),
200 crate::undo_action_id(true),
201 crate::edit_action_id(day_spec::EditOp::Cut),
202 crate::edit_action_id(day_spec::EditOp::Copy),
203 crate::edit_action_id(day_spec::EditOp::Paste),
204 crate::edit_action_id(day_spec::EditOp::SelectAll),
205 ];
206 let stale: Vec<u64> = APP_MENU_IDS.with(|ids| {
207 ids.borrow()
208 .iter()
209 .copied()
210 .filter(|id| !new_ids.contains(id) && !durable.contains(id))
211 .collect()
212 });
213 ACTIONS.with(|m| {
214 let mut m = m.borrow_mut();
215 for id in stale {
216 m.remove(&id);
217 }
218 });
219 APP_MENU_IDS.with(|ids| *ids.borrow_mut() = new_ids);
220 APP_MENU_MODEL.with(|m| *m.borrow_mut() = items.clone());
221 crate::with_tree(|t| t.set_app_menu(items));
222}
223
224pub fn set_dock_menu(items: Vec<day_spec::MenuItem>) {
229 let new_ids = collect_action_ids(&items);
230 let stale: Vec<u64> = DOCK_MENU_IDS.with(|ids| {
231 ids.borrow()
232 .iter()
233 .copied()
234 .filter(|id| !new_ids.contains(id))
235 .collect()
236 });
237 ACTIONS.with(|m| {
238 let mut m = m.borrow_mut();
239 for id in stale {
240 m.remove(&id);
241 }
242 });
243 DOCK_MENU_IDS.with(|ids| *ids.borrow_mut() = new_ids);
244 DOCK_MENU_MODEL.with(|m| *m.borrow_mut() = items);
245 install_dock_menu();
246}
247
248pub(crate) fn install_dock_menu() {
252 let mut items = crate::shortcuts::shortcut_menu_items();
253 let app = DOCK_MENU_MODEL.with(|m| m.borrow().clone());
254 if !items.is_empty() && !app.is_empty() {
255 items.push(day_spec::MenuItem::Separator);
256 }
257 items.extend(app);
258 if items.is_empty() && !DOCK_MENU_SHOWN.with(|c| c.get()) {
260 return;
261 }
262 DOCK_MENU_SHOWN.with(|c| c.set(!items.is_empty()));
263 crate::with_tree(|t| t.set_dock_menu(&items));
264}
265
266pub fn app_menu_model() -> Vec<day_spec::MenuItem> {
269 APP_MENU_MODEL.with(|m| m.borrow().clone())
270}
271
272pub fn app_menu_installed() -> bool {
274 APP_MENU_MODEL.with(|m| !m.borrow().is_empty())
275}
276
277pub fn has_app_menu() -> bool {
284 APP_MENU_MODEL.with(|m| !m.borrow().is_empty())
285}
286
287pub(crate) fn reinstall_app_menu() {
288 let items = APP_MENU_MODEL.with(|m| m.borrow().clone());
289 if !items.is_empty() {
290 set_app_menu(items);
291 }
292}
293
294fn inject_preferences(mut items: Vec<day_spec::MenuItem>) -> Vec<day_spec::MenuItem> {
301 let prefs_id = crate::windows::preferences_action_id();
302 if prefs_id == 0 {
303 return items;
304 }
305 fn rewrite(items: &mut [day_spec::MenuItem], prefs_id: u64) -> bool {
306 use day_spec::MenuItem as M;
307 for it in items.iter_mut() {
308 match it {
309 M::Action { action, role, .. }
310 if *role == Some(day_spec::MenuRole::Preferences) =>
311 {
312 if *action == 0 {
313 *action = prefs_id;
314 }
315 return true;
316 }
317 M::Submenu { items, .. } => {
318 if rewrite(items, prefs_id) {
319 return true;
320 }
321 }
322 _ => {}
323 }
324 }
325 false
326 }
327 if rewrite(&mut items, prefs_id) {
328 return items;
329 }
330 let item = day_spec::MenuItem::Action {
331 id: None,
332 action: prefs_id,
333 label: String::new(),
336 shortcut: Some(day_spec::Shortcut::primary(",")),
337 enabled: true,
338 checked: None,
339 role: Some(day_spec::MenuRole::Preferences),
340 icon: None,
341 };
342 if let Some(day_spec::MenuItem::Submenu { items: first, .. }) = items
343 .iter_mut()
344 .find(|it| matches!(it, day_spec::MenuItem::Submenu { .. }))
345 {
346 first.push(day_spec::MenuItem::Separator);
347 first.push(item);
348 } else {
349 items.push(item);
350 }
351 items
352}
353
354fn inject_about(mut items: Vec<day_spec::MenuItem>) -> Vec<day_spec::MenuItem> {
362 use day_spec::{MenuItem as M, MenuRole as R};
363 let about_id = crate::windows::about_action_id();
364 let updates_id = crate::windows::check_for_updates_action_id();
365 if about_id == 0 && updates_id == 0 {
366 return items;
367 }
368 fn rewrite(items: &mut [M], role: R, id: u64) -> bool {
370 let mut found = false;
371 for it in items.iter_mut() {
372 match it {
373 M::Action {
374 action, role: r, ..
375 } if *r == Some(role) => {
376 if *action == 0 {
377 *action = id;
378 }
379 found = true;
380 }
381 M::Submenu { items, .. } => {
382 found |= rewrite(items, role, id);
383 }
384 _ => {}
385 }
386 }
387 found
388 }
389 let has_about = rewrite(&mut items, R::About, about_id);
390 let has_updates = rewrite(&mut items, R::CheckForUpdates, updates_id);
391 let act = |role: R, action: u64| M::Action {
392 id: None,
393 action,
394 label: String::new(),
396 shortcut: None,
397 enabled: true,
398 checked: None,
399 role: Some(role),
400 icon: None,
401 };
402 let mut missing = Vec::new();
403 if about_id != 0 && !has_about {
404 missing.push(act(R::About, about_id));
405 }
406 if updates_id != 0 && !has_updates {
407 missing.push(act(R::CheckForUpdates, updates_id));
408 }
409 if missing.is_empty() {
410 return items;
411 }
412 if let Some(M::Submenu { items: help, .. }) = items.iter_mut().find(|it| {
414 matches!(it, M::Submenu { role, label, .. }
415 if *role == Some(day_spec::MenuBarRole::Help) || *label == day_localization::t(day_localization::key::day_help))
416 }) {
417 help.extend(missing);
418 }
419 items
420}
421
422pub(crate) fn collect_ids(items: &[day_spec::MenuItem]) -> Vec<u64> {
424 collect_action_ids(items)
425}
426
427pub(crate) fn forget_actions(ids: &[u64]) {
429 ACTIONS.with(|m| {
430 let mut m = m.borrow_mut();
431 for id in ids {
432 m.remove(id);
433 }
434 });
435}
436
437fn collect_action_ids(items: &[day_spec::MenuItem]) -> Vec<u64> {
438 let mut ids = Vec::new();
439 fn walk(items: &[day_spec::MenuItem], ids: &mut Vec<u64>) {
440 for it in items {
441 match it {
442 day_spec::MenuItem::Action { action, .. } if *action != 0 => ids.push(*action),
443 day_spec::MenuItem::Submenu { items, .. } => walk(items, ids),
444 _ => {}
445 }
446 }
447 }
448 walk(items, &mut ids);
449 ids
450}
451
452#[derive(Clone, Copy, Debug, PartialEq, Eq)]
458#[non_exhaustive]
459pub enum MenuBarStyle {
460 Macos,
462 Gnome,
464 Kde,
466 Windows,
468}
469
470impl MenuBarStyle {
471 pub fn bar_order(
473 self,
474 ) -> (
475 &'static [day_spec::MenuBarRole],
476 &'static [day_spec::MenuBarRole],
477 ) {
478 use day_spec::MenuBarRole as B;
479 const LEADING: &[day_spec::MenuBarRole] = &[B::File, B::Edit, B::View];
480 match self {
481 MenuBarStyle::Macos => (LEADING, &[B::Window, B::Help]),
483 MenuBarStyle::Gnome | MenuBarStyle::Windows => (LEADING, &[B::Help]),
484 MenuBarStyle::Kde => (LEADING, &[B::Settings, B::Help]),
485 }
486 }
487
488 pub fn stock(self, role: day_spec::MenuBarRole) -> Option<day_spec::MenuItem> {
497 use day_spec::{MenuBarRole as B, MenuItem as MI, MenuRole as R};
498 let act = |r: R| MI::Action {
502 id: None,
503 action: match r {
504 R::About => crate::windows::about_action_id(),
505 R::CheckForUpdates => crate::windows::check_for_updates_action_id(),
506 _ => 0,
507 },
508 label: String::new(),
509 shortcut: None,
510 enabled: true,
511 checked: None,
512 role: Some(r),
513 icon: None,
514 };
515 let sub = |key: &str, items: Vec<MI>| {
516 Some(MI::Submenu {
517 label: day_localization::t(key),
518 items,
519 role: None,
520 })
521 };
522 match role {
523 B::Edit => sub(
524 "day_edit",
525 vec![
526 act(R::Undo),
527 act(R::Redo),
528 MI::Separator,
529 act(R::Cut),
530 act(R::Copy),
531 act(R::Paste),
532 act(R::Delete),
533 act(R::SelectAll),
534 ],
535 ),
536 B::View => sub("day_view", vec![act(R::Fullscreen)]),
537 B::Help => match self {
540 MenuBarStyle::Macos => sub("day_help", Vec::new()),
541 _ => {
542 let mut items = vec![act(R::About)];
543 if crate::windows::check_for_updates_action_id() != 0 {
544 items.push(act(R::CheckForUpdates));
545 }
546 sub("day_help", items)
547 }
548 },
549 _ => None,
550 }
551 }
552}
553
554pub fn standard_menu_bar_for(
557 style: MenuBarStyle,
558 app_menus: Vec<day_spec::MenuItem>,
559) -> Vec<day_spec::MenuItem> {
560 let (leading, trailing) = style.bar_order();
561 standard_menu_bar(app_menus, leading, trailing, |r| style.stock(r))
562}
563
564pub fn standard_menu_bar(
571 app_menus: Vec<day_spec::MenuItem>,
572 leading: &[day_spec::MenuBarRole],
573 trailing: &[day_spec::MenuBarRole],
574 stock: impl Fn(day_spec::MenuBarRole) -> Option<day_spec::MenuItem>,
575) -> Vec<day_spec::MenuItem> {
576 use day_spec::MenuBarRole as R;
577 let _ = R::File;
578
579 let mut claimed: Vec<(R, day_spec::MenuItem)> = Vec::new();
580 let mut own: Vec<day_spec::MenuItem> = Vec::new();
581 for item in app_menus {
582 match &item {
583 day_spec::MenuItem::Submenu { role: Some(r), .. } => claimed.push((*r, item)),
584 _ => own.push(item),
585 }
586 }
587 let mut out: Vec<day_spec::MenuItem> = leading
588 .iter()
589 .filter_map(|r| take_slot(*r, &mut claimed, &mut own, &stock))
590 .collect();
591 let trailing_menus: Vec<day_spec::MenuItem> = trailing
592 .iter()
593 .filter_map(|r| take_slot(*r, &mut claimed, &mut own, &stock))
594 .collect();
595 out.append(&mut own);
596 out.extend(trailing_menus);
597 out.extend(claimed.into_iter().map(|(_, m)| m));
599 out
600}
601
602fn take_slot(
611 role: day_spec::MenuBarRole,
612 claimed: &mut Vec<(day_spec::MenuBarRole, day_spec::MenuItem)>,
613 own: &mut Vec<day_spec::MenuItem>,
614 stock: &impl Fn(day_spec::MenuBarRole) -> Option<day_spec::MenuItem>,
615) -> Option<day_spec::MenuItem> {
616 if let Some(i) = claimed.iter().position(|(cr, _)| *cr == role) {
617 return Some(claimed.remove(i).1);
618 }
619 if let Some(title) = role_title(role)
624 && let Some(i) = own
625 .iter()
626 .position(|m| submenu_label(m).is_some_and(|l| menu_titles_match(l, &title)))
627 {
628 return Some(own.remove(i));
629 }
630 stock(role)
631}
632
633fn role_title(role: day_spec::MenuBarRole) -> Option<String> {
636 use day_spec::MenuBarRole as B;
637 let key = match role {
638 B::File => "day_file",
639 B::Edit => "day_edit",
640 B::View => "day_view",
641 B::Window => "day_window",
642 B::Help => "day_help",
643 _ => return None,
644 };
645 Some(day_localization::t(key))
646}
647
648fn submenu_label(item: &day_spec::MenuItem) -> Option<&str> {
649 match item {
650 day_spec::MenuItem::Submenu { label, .. } => Some(label),
651 _ => None,
652 }
653}
654
655fn menu_titles_match(a: &str, b: &str) -> bool {
660 let norm = |s: &str| s.replace('&', "").trim().to_lowercase();
661 norm(a) == norm(b)
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use std::cell::Cell;
668
669 use day_spec::MenuBarRole as BR;
670 const MAC_LEADING: &[BR] = &[BR::File, BR::Edit, BR::View];
671 const MAC_TRAILING: &[BR] = &[BR::Window, BR::Help];
672
673 fn sub(label: &str, role: Option<day_spec::MenuBarRole>) -> day_spec::MenuItem {
674 day_spec::MenuItem::Submenu {
675 label: label.into(),
676 items: Vec::new(),
677 role,
678 }
679 }
680
681 fn labels(items: &[day_spec::MenuItem]) -> Vec<String> {
682 items
683 .iter()
684 .map(|i| match i {
685 day_spec::MenuItem::Submenu { label, .. } => label.clone(),
686 _ => String::new(),
687 })
688 .collect()
689 }
690
691 #[test]
693 fn stock_menus_fill_the_slots_an_app_left_open() {
694 use day_spec::MenuBarRole as R;
695 let out = standard_menu_bar(
696 vec![sub("Go", None), sub("Article", None)],
697 MAC_LEADING,
698 MAC_TRAILING,
699 |r| {
700 Some(sub(
701 match r {
702 R::File => "File",
703 R::Edit => "Edit",
704 R::View => "View",
705 R::Window => "Window",
706 R::Help => "Help",
707 _ => "?",
708 },
709 Some(r),
710 ))
711 },
712 );
713 assert_eq!(
714 labels(&out),
715 ["File", "Edit", "View", "Go", "Article", "Window", "Help"]
716 );
717 }
718
719 #[test]
724 fn an_untagged_menu_named_like_a_stock_one_takes_its_slot() {
725 let bar = standard_menu_bar(
726 vec![sub("File", None), sub("Edit", None), sub("View", None)],
727 &[BR::File, BR::Edit, BR::View],
728 &[BR::Window, BR::Help],
729 |r| match r {
730 BR::File => None,
732 BR::Edit => Some(sub("Edit", Some(BR::Edit))),
733 BR::View => Some(sub("View", Some(BR::View))),
734 BR::Window => Some(sub("Window", Some(BR::Window))),
735 _ => Some(sub("Help", Some(BR::Help))),
736 },
737 );
738 assert_eq!(
739 labels(&bar),
740 ["File", "Edit", "View", "Window", "Help"],
741 "a hand-built standard menu must not double the stock one"
742 );
743 }
744
745 #[test]
747 fn menu_titles_match_across_mnemonics_and_case() {
748 assert!(menu_titles_match("&View", "View"));
749 assert!(menu_titles_match("edit ", "Edit"));
750 assert!(menu_titles_match("Ansicht", "ansicht"));
751 assert!(!menu_titles_match("Edit", "View"));
752 }
753
754 #[test]
757 fn a_claimed_slot_replaces_the_stock_menu_in_place() {
758 use day_spec::MenuBarRole as R;
759 let out = standard_menu_bar(
760 vec![sub("My File", Some(R::File)), sub("Go", None)],
761 MAC_LEADING,
762 MAC_TRAILING,
763 |r| Some(sub("stock", Some(r))),
764 );
765 let l = labels(&out);
766 assert_eq!(l[0], "My File", "the app's File sits in the File slot");
767 assert_eq!(l.iter().filter(|s| *s == "My File").count(), 1);
768 assert!(!l.contains(&"File".to_string()));
769 assert_eq!(l[3], "Go", "app menus still follow the leading slots");
770 }
771
772 #[test]
775 fn each_platform_gets_its_own_bar_order() {
776 use day_spec::MenuBarRole as R;
777 let stock = |r: R| {
779 let label = match r {
780 R::Edit => "Edit",
781 R::View => "View",
782 R::Settings => "Settings",
783 R::Help => "Help",
784 R::Window => "Window",
785 _ => return None,
786 };
787 Some(sub(label, Some(r)))
788 };
789 let kde = standard_menu_bar(
790 vec![sub("Go", None)],
791 &[R::File, R::Edit, R::View],
792 &[R::Settings, R::Help],
793 stock,
794 );
795 assert_eq!(labels(&kde), ["Edit", "View", "Go", "Settings", "Help"]);
796
797 let gnome = standard_menu_bar(
798 vec![sub("Go", None)],
799 &[R::File, R::Edit, R::View],
800 &[R::Help],
801 stock,
802 );
803 assert_eq!(labels(&gnome), ["Edit", "View", "Go", "Help"]);
804 assert!(!labels(&gnome).contains(&"Window".to_string()));
805 }
806
807 #[test]
809 fn a_slot_with_no_stock_menu_is_omitted() {
810 use day_spec::MenuBarRole as R;
811 let out = standard_menu_bar(vec![sub("Go", None)], MAC_LEADING, MAC_TRAILING, |r| {
812 (r == R::Edit).then(|| sub("Edit", Some(r)))
813 });
814 assert_eq!(labels(&out), ["Edit", "Go"]);
815 }
816
817 #[test]
818 fn dispatch_runs_the_registered_action_by_id() {
819 thread_local! { static FIRED: Cell<u32> = const { Cell::new(0) }; }
820 let id = register_menu_action(Rc::new(|| FIRED.with(|c| c.set(c.get() + 1))));
821 assert_ne!(
822 id, 0,
823 "ids are nonzero so role-only items (id 0) never dispatch"
824 );
825 assert_eq!(FIRED.with(Cell::get), 0);
826
827 dispatch_menu_action(id);
828 assert_eq!(FIRED.with(Cell::get), 1, "the closure ran exactly once");
829
830 let id2 = register_menu_action(Rc::new(|| {}));
832 assert_ne!(id, id2);
833
834 dispatch_menu_action(0);
836 dispatch_menu_action(u64::MAX);
837 assert_eq!(FIRED.with(Cell::get), 1);
838 }
839
840 #[test]
841 fn scoped_action_is_reclaimed_when_its_scope_dies() {
842 let scope = day_reactive::Scope::child();
843 let id = scope.enter(|| register_scoped_menu_action(Rc::new(|| {})));
844 assert!(
845 ACTIONS.with(|m| m.borrow().contains_key(&id)),
846 "registered while the scope is alive"
847 );
848 scope.dispose();
849 assert!(
850 !ACTIONS.with(|m| m.borrow().contains_key(&id)),
851 "disposal must reclaim the closure — this is the remount leak"
852 );
853 dispatch_menu_action(id); }
855}