1use std::collections::HashMap;
13
14use teksilo_core::MenuItemId;
15use teksilo_core::ObserverHandle;
16use teksilo_core::build_context::BuildContext;
17use teksilo_core::event::{Key, Modifiers};
18use teksilo_core::shortcut::KeyStroke;
19use teksilo_core::signal::{Prop, Signal};
20use teksilo_data::CheckState;
21use teksilo_i18n::LocalizedString;
22use teksilo_platform::native_menu::{
23 MenuItemDelta, NativeCheck, NativeKeyEquivalent, NativeMenuActivation, NativeMenuHandle,
24 NativeMenuNode, NativeMenuSnapshot, StandardMenuRole, StandardRoutedItem,
25};
26
27use crate::menu_item::parse_mnemonic;
28
29use super::model::{MenuItemState, MenuModel, MenuNode, StandardMenu};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum NativeMenuMode {
36 #[default]
39 Off,
40 Suppress,
44 Coexist,
46}
47
48impl NativeMenuMode {
49 pub(crate) fn suppresses_in_window(self) -> bool {
51 cfg!(target_os = "macos") && matches!(self, NativeMenuMode::Suppress)
52 }
53
54 pub(crate) fn installs_native(self) -> bool {
56 !matches!(self, NativeMenuMode::Off)
57 }
58}
59
60pub(crate) struct NativeMenuBinding {
65 _observers: Vec<ObserverHandle>,
66}
67
68pub(crate) fn install(model: &MenuModel, ctx: &BuildContext) -> Option<NativeMenuBinding> {
73 let handle = ctx.app_state::<NativeMenuHandle>()?.clone();
74 let window_id = ctx.window()?.id();
75 let poster = ctx.poster()?.clone();
76
77 let mut activations = HashMap::new();
78 let mut reactive = Vec::new();
79 let mut roots: Vec<NativeMenuNode> = {
80 let nodes = model.nodes();
81 nodes
82 .iter()
83 .filter_map(|n| resolve_node(n, ctx, &mut activations, &mut reactive))
84 .collect()
85 };
86 let has_app = roots.iter().any(|n| {
91 matches!(
92 n,
93 NativeMenuNode::Standard {
94 role: StandardMenuRole::App,
95 ..
96 }
97 )
98 });
99 if !has_app {
100 roots.insert(
101 0,
102 NativeMenuNode::Standard {
103 role: StandardMenuRole::App,
104 labels: StandardMenu::app().resolve_labels(),
105 quit_item: None,
109 settings_item: None,
113 },
114 );
115 }
116 let snapshot = NativeMenuSnapshot { roots };
117
118 handle.set_window_menu(window_id, snapshot, activations, poster);
119
120 let mut observers = Vec::new();
122 for item in reactive {
123 {
128 let sig = item.title.to_signal();
129 let h = handle.clone();
130 let id = item.id;
131 push_observer(&mut observers, &sig, move |v| {
132 h.update_item(
133 id,
134 MenuItemDelta {
135 title: Some(strip_title(v)),
136 ..Default::default()
137 },
138 );
139 });
140 }
141 if let Prop::Bound(sig) = item.enabled {
142 let h = handle.clone();
143 let id = item.id;
144 push_observer(&mut observers, &sig, move |v| {
145 h.update_item(
146 id,
147 MenuItemDelta {
148 enabled: Some(*v),
149 ..Default::default()
150 },
151 );
152 });
153 }
154 match item.state {
155 MenuItemState::Plain => {}
156 MenuItemState::Check(sig) | MenuItemState::ReflectCheck(sig) => {
159 let h = handle.clone();
160 let id = item.id;
161 push_observer(&mut observers, &sig, move |v| {
162 h.update_item(
163 id,
164 check_delta(if *v {
165 NativeCheck::On
166 } else {
167 NativeCheck::Off
168 }),
169 );
170 });
171 }
172 MenuItemState::TriCheck(sig) => {
173 let h = handle.clone();
174 let id = item.id;
175 push_observer(&mut observers, &sig, move |v| {
176 h.update_item(id, check_delta(tri_to_native(*v)));
177 });
178 }
179 MenuItemState::Radio { value, selected } => {
180 let h = handle.clone();
181 let id = item.id;
182 push_observer(&mut observers, &selected, move |sel| {
183 let check = if *sel == value {
184 NativeCheck::On
185 } else {
186 NativeCheck::Off
187 };
188 h.update_item(id, check_delta(check));
189 });
190 }
191 }
192 }
193
194 Some(NativeMenuBinding {
195 _observers: observers,
196 })
197}
198
199fn push_observer<T: 'static>(
214 observers: &mut Vec<ObserverHandle>,
215 signal: &Signal<T>,
216 f: impl Fn(&T) + 'static,
217) {
218 if let Ok(handle) = signal.try_observe(f) {
219 observers.push(handle);
220 }
221}
222
223struct ReactiveItem {
225 id: MenuItemId,
226 enabled: Prop<bool>,
227 state: MenuItemState,
228 title: LocalizedString,
233}
234
235fn resolve_node(
236 node: &MenuNode,
237 ctx: &BuildContext,
238 activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
239 reactive: &mut Vec<ReactiveItem>,
240) -> Option<NativeMenuNode> {
241 match node {
242 MenuNode::Separator => Some(NativeMenuNode::Separator),
243 MenuNode::Standard(sm) => Some(resolve_standard(sm, activations, |id| {
244 ctx.effective_shortcut(id).and_then(|eff| eff.primary)
245 })),
246 MenuNode::Submenu {
247 title, children, ..
248 } => Some(NativeMenuNode::Submenu {
249 title: strip_title(&title.resolve_now()),
250 children: children
251 .iter()
252 .filter_map(|n| resolve_node(n, ctx, activations, reactive))
253 .collect(),
254 }),
255 MenuNode::Item(entry) if !entry.visible.get() => None,
259 MenuNode::Item(entry) => {
260 let check = match &entry.state {
261 MenuItemState::Plain => NativeCheck::None,
262 MenuItemState::Check(s) | MenuItemState::ReflectCheck(s) => {
263 if s.get() {
264 NativeCheck::On
265 } else {
266 NativeCheck::Off
267 }
268 }
269 MenuItemState::TriCheck(s) => tri_to_native(s.get()),
270 MenuItemState::Radio { value, selected } => {
271 if selected.get() == *value {
272 NativeCheck::On
273 } else {
274 NativeCheck::Off
275 }
276 }
277 };
278 let key_equiv = entry
279 .shortcut_id
280 .and_then(|id| ctx.effective_shortcut(id).and_then(|eff| eff.primary))
281 .map(native_key_equiv);
282
283 activations.insert(
284 entry.id,
285 NativeMenuActivation {
286 intent: entry.intent,
287 action: entry.action.clone(),
288 },
289 );
290 reactive.push(ReactiveItem {
291 id: entry.id,
292 enabled: entry.enabled.clone(),
293 state: entry.state.clone(),
294 title: entry.title.clone(),
295 });
296
297 Some(NativeMenuNode::Item {
298 id: entry.id,
299 title: strip_title(&entry.title.resolve_now()),
300 key_equiv,
301 enabled: entry.enabled.get(),
302 check,
303 })
304 }
305 }
306}
307
308fn conventional_chord(key: &str) -> NativeKeyEquivalent {
315 NativeKeyEquivalent {
316 key: key.to_string(),
317 command: true,
318 shift: false,
319 alt: false,
320 control: false,
321 }
322}
323
324fn resolve_standard(
337 sm: &StandardMenu,
338 activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
339 shortcut: impl Fn(&str) -> Option<KeyStroke>,
340) -> NativeMenuNode {
341 let mut route = |entry: Option<(&'static str, MenuItemId)>,
345 shortcut_id: Option<&'static str>,
346 fallback: &str|
347 -> Option<StandardRoutedItem> {
348 let (intent, id) = entry?;
349 activations.insert(
350 id,
351 NativeMenuActivation {
352 intent: Some(intent),
353 action: None,
354 },
355 );
356 let key_equiv = match shortcut_id {
363 Some(sid) => shortcut(sid).map(native_key_equiv),
364 None => Some(conventional_chord(fallback)),
365 };
366 Some(StandardRoutedItem { id, key_equiv })
367 };
368
369 let quit_item = route(sm.quit_route(), sm.quit_shortcut_id(), "q");
370 let settings_item = route(sm.settings_route(), sm.settings_shortcut_id(), ",");
373
374 NativeMenuNode::Standard {
375 role: sm.role(),
376 labels: sm.resolve_labels(),
377 quit_item,
378 settings_item,
379 }
380}
381
382fn check_delta(check: NativeCheck) -> MenuItemDelta {
383 MenuItemDelta {
384 check: Some(check),
385 ..Default::default()
386 }
387}
388
389fn tri_to_native(state: CheckState) -> NativeCheck {
390 match state {
391 CheckState::Checked => NativeCheck::On,
392 CheckState::Unchecked => NativeCheck::Off,
393 CheckState::Indeterminate => NativeCheck::Mixed,
394 }
395}
396
397fn strip_title(raw: &str) -> String {
398 parse_mnemonic(raw).stripped
399}
400
401fn native_key_equiv(ks: KeyStroke) -> NativeKeyEquivalent {
414 NativeKeyEquivalent {
415 key: key_to_equiv(ks.key),
416 command: ks.modifiers.command() || ks.modifiers.super_key(),
417 shift: ks.modifiers.shift(),
418 alt: ks.modifiers.alt(),
419 control: ks.modifiers.without(Modifiers::COMMAND).ctrl(),
420 }
421}
422
423fn key_to_equiv(key: Key) -> String {
424 let special = match key {
425 Key::Enter => "\r",
426 Key::Tab => "\t",
427 Key::Space => " ",
428 Key::Escape => "\u{1b}",
429 Key::Backspace => "\u{8}",
430 Key::Delete => "\u{7f}",
431 Key::ArrowUp => "\u{F700}",
432 Key::ArrowDown => "\u{F701}",
433 Key::ArrowLeft => "\u{F702}",
434 Key::ArrowRight => "\u{F703}",
435 Key::Home => "\u{F729}",
436 Key::End => "\u{F72B}",
437 Key::PageUp => "\u{F72C}",
438 Key::PageDown => "\u{F72D}",
439 Key::F1 => "\u{F704}",
440 Key::F2 => "\u{F705}",
441 Key::F3 => "\u{F706}",
442 Key::F4 => "\u{F707}",
443 Key::F5 => "\u{F708}",
444 Key::F6 => "\u{F709}",
445 Key::F7 => "\u{F70A}",
446 Key::F8 => "\u{F70B}",
447 Key::F9 => "\u{F70C}",
448 Key::F10 => "\u{F70D}",
449 Key::F11 => "\u{F70E}",
450 Key::F12 => "\u{F70F}",
451 other => return other.to_char().map(|c| c.to_string()).unwrap_or_default(),
453 };
454 special.to_string()
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use teksilo_i18n::LocalizedString;
461
462 fn labels_of(node: &NativeMenuNode) -> &teksilo_platform::native_menu::StandardLabels {
463 match node {
464 NativeMenuNode::Standard { labels, .. } => labels,
465 _ => panic!("expected a standard menu node"),
466 }
467 }
468
469 fn quit_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
470 match node {
471 NativeMenuNode::Standard { quit_item, .. } => quit_item.as_ref(),
472 _ => panic!("expected a standard menu node"),
473 }
474 }
475
476 fn settings_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
477 match node {
478 NativeMenuNode::Standard { settings_item, .. } => settings_item.as_ref(),
479 _ => panic!("expected a standard menu node"),
480 }
481 }
482
483 fn quit_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
484 quit_of(node).map(|r| r.id)
485 }
486
487 fn settings_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
488 settings_of(node).map(|r| r.id)
489 }
490
491 fn no_shortcuts(_: &str) -> Option<KeyStroke> {
493 None
494 }
495
496 fn only(id: &'static str, ks: KeyStroke) -> impl Fn(&str) -> Option<KeyStroke> {
498 move |asked| (asked == id).then_some(ks)
499 }
500
501 fn chord(item: Option<&StandardRoutedItem>) -> Option<(String, bool, bool)> {
503 item?
504 .key_equiv
505 .as_ref()
506 .map(|k| (k.key.clone(), k.command, k.shift))
507 }
508
509 #[test]
513 fn a_standard_app_menu_has_no_settings_row_by_default() {
514 let mut activations = HashMap::new();
515 let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
516 assert_eq!(settings_item_of(&node), None);
517 }
518
519 #[test]
521 fn a_settings_intent_becomes_a_routed_item_with_an_activation() {
522 let mut activations = HashMap::new();
523 let node = resolve_standard(
524 &StandardMenu::app().settings_intent("app.settings"),
525 &mut activations,
526 no_shortcuts,
527 );
528 let id = settings_item_of(&node).expect("a routed settings carries an item id");
529 assert_eq!(
530 activations.get(&id).map(|a| a.intent),
531 Some(Some("app.settings"))
532 );
533 }
534
535 #[test]
538 fn quit_and_settings_are_routed_under_distinct_ids() {
539 let mut activations = HashMap::new();
540 let node = resolve_standard(
541 &StandardMenu::app()
542 .quit_intent("app.quit")
543 .settings_intent("app.settings"),
544 &mut activations,
545 no_shortcuts,
546 );
547 let quit = quit_item_of(&node).expect("quit id");
548 let settings = settings_item_of(&node).expect("settings id");
549 assert_ne!(quit, settings);
550 assert_eq!(activations.len(), 2);
551 assert_eq!(activations[&quit].intent, Some("app.quit"));
552 assert_eq!(activations[&settings].intent, Some("app.settings"));
553 }
554
555 #[test]
558 fn the_routed_settings_id_is_stable_across_installs() {
559 let menu = StandardMenu::app().settings_intent("app.settings");
560 let mut first = HashMap::new();
561 let mut second = HashMap::new();
562 assert_eq!(
563 settings_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
564 settings_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
565 );
566 }
567
568 #[test]
572 fn the_settings_label_resolves_through_the_widget_layer() {
573 let mut activations = HashMap::new();
574 let node = resolve_standard(
575 &StandardMenu::app().settings(LocalizedString::literal("Réglages…")),
576 &mut activations,
577 no_shortcuts,
578 );
579 assert_eq!(labels_of(&node).settings, "Réglages…");
580 }
581
582 #[test]
586 fn a_standard_app_menu_routes_nothing_by_default() {
587 let mut activations = HashMap::new();
588 let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
589 assert_eq!(quit_item_of(&node), None);
590 assert!(
591 activations.is_empty(),
592 "an unrouted standard menu owns no activation"
593 );
594 }
595
596 #[test]
600 fn a_quit_intent_becomes_a_routed_item_with_an_activation() {
601 let mut activations = HashMap::new();
602 let node = resolve_standard(
603 &StandardMenu::app().quit_intent("app.quit"),
604 &mut activations,
605 no_shortcuts,
606 );
607 let id = quit_item_of(&node).expect("a routed quit carries an item id");
608 let activation = activations
609 .get(&id)
610 .expect("the routed id resolves to an activation");
611 assert_eq!(activation.intent, Some("app.quit"));
612 assert!(
613 activation.action.is_none(),
614 "routing by name only — no closure to run on the side"
615 );
616 }
617
618 #[test]
623 fn the_routed_quit_id_is_stable_across_installs() {
624 let menu = StandardMenu::app().quit_intent("app.quit");
625 let mut first = HashMap::new();
626 let mut second = HashMap::new();
627 assert_eq!(
628 quit_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
629 quit_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
630 );
631 }
632
633 #[test]
637 fn two_app_menus_get_distinct_routed_ids() {
638 let mut activations = HashMap::new();
639 let a = resolve_standard(
640 &StandardMenu::app().quit_intent("app.quit"),
641 &mut activations,
642 no_shortcuts,
643 );
644 let b = resolve_standard(
645 &StandardMenu::app().quit_intent("app.quit"),
646 &mut activations,
647 no_shortcuts,
648 );
649 assert_ne!(quit_item_of(&a), quit_item_of(&b));
650 assert_eq!(activations.len(), 2);
651 }
652
653 #[test]
656 fn routing_leaves_the_localized_labels_alone() {
657 let mut activations = HashMap::new();
658 let node = resolve_standard(
659 &StandardMenu::app()
660 .quit(LocalizedString::literal("Quitter"))
661 .quit_intent("app.quit"),
662 &mut activations,
663 no_shortcuts,
664 );
665 assert_eq!(labels_of(&node).quit, "Quitter");
666 }
667
668 #[test]
674 fn an_unnamed_shortcut_falls_back_to_the_conventional_chord() {
675 let mut activations = HashMap::new();
676 let node = resolve_standard(
677 &StandardMenu::app()
678 .quit_intent("app.quit")
679 .settings_intent("app.settings"),
680 &mut activations,
681 no_shortcuts,
682 );
683 assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
684 assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
685 }
686
687 #[test]
691 fn a_named_shortcut_supplies_the_chord() {
692 let mut activations = HashMap::new();
693 let node = resolve_standard(
694 &StandardMenu::app()
695 .quit_intent("app.quit")
696 .quit_shortcut("app.quit"),
697 &mut activations,
698 only("app.quit", KeyStroke::command(Key::Q)),
699 );
700 assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
701 }
702
703 #[test]
709 fn a_rebound_shortcut_moves_the_rows_chord_with_it() {
710 let mut activations = HashMap::new();
711 let node = resolve_standard(
712 &StandardMenu::app()
713 .quit_intent("app.quit")
714 .quit_shortcut("app.quit"),
715 &mut activations,
716 only("app.quit", KeyStroke::command_shift(Key::Q)),
717 );
718 assert_eq!(
719 chord(quit_of(&node)),
720 Some(("q".into(), true, true)),
721 "the row follows the rebind rather than keeping the convention"
722 );
723 }
724
725 #[test]
730 fn a_named_but_unbound_shortcut_leaves_the_row_chordless() {
731 let mut activations = HashMap::new();
732 let node = resolve_standard(
733 &StandardMenu::app()
734 .quit_intent("app.quit")
735 .quit_shortcut("app.quit"),
736 &mut activations,
737 no_shortcuts,
738 );
739 assert!(quit_of(&node).is_some(), "the row is still there");
740 assert_eq!(chord(quit_of(&node)), None, "it just has no chord");
741 }
742
743 #[test]
745 fn each_row_reads_its_own_shortcut() {
746 let mut activations = HashMap::new();
747 let node = resolve_standard(
748 &StandardMenu::app()
749 .quit_intent("app.quit")
750 .quit_shortcut("app.quit")
751 .settings_intent("app.settings")
752 .settings_shortcut("app.settings"),
753 &mut activations,
754 only("app.settings", KeyStroke::command(Key::Character(','))),
755 );
756 assert_eq!(chord(quit_of(&node)), None, "quit's id resolves to nothing");
757 assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
758 }
759}