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;
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 observers.push(sig.observe(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 observers.push(sig.observe(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 observers.push(sig.observe(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 observers.push(sig.observe(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 observers.push(selected.observe(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
199struct ReactiveItem {
201 id: MenuItemId,
202 enabled: Prop<bool>,
203 state: MenuItemState,
204 title: LocalizedString,
209}
210
211fn resolve_node(
212 node: &MenuNode,
213 ctx: &BuildContext,
214 activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
215 reactive: &mut Vec<ReactiveItem>,
216) -> Option<NativeMenuNode> {
217 match node {
218 MenuNode::Separator => Some(NativeMenuNode::Separator),
219 MenuNode::Standard(sm) => Some(resolve_standard(sm, activations, |id| {
220 ctx.effective_shortcut(id).and_then(|eff| eff.primary)
221 })),
222 MenuNode::Submenu {
223 title, children, ..
224 } => Some(NativeMenuNode::Submenu {
225 title: strip_title(&title.resolve_now()),
226 children: children
227 .iter()
228 .filter_map(|n| resolve_node(n, ctx, activations, reactive))
229 .collect(),
230 }),
231 MenuNode::Item(entry) if !entry.visible.get() => None,
235 MenuNode::Item(entry) => {
236 let check = match &entry.state {
237 MenuItemState::Plain => NativeCheck::None,
238 MenuItemState::Check(s) | MenuItemState::ReflectCheck(s) => {
239 if s.get() {
240 NativeCheck::On
241 } else {
242 NativeCheck::Off
243 }
244 }
245 MenuItemState::TriCheck(s) => tri_to_native(s.get()),
246 MenuItemState::Radio { value, selected } => {
247 if selected.get() == *value {
248 NativeCheck::On
249 } else {
250 NativeCheck::Off
251 }
252 }
253 };
254 let key_equiv = entry
255 .shortcut_id
256 .and_then(|id| ctx.effective_shortcut(id).and_then(|eff| eff.primary))
257 .map(native_key_equiv);
258
259 activations.insert(
260 entry.id,
261 NativeMenuActivation {
262 intent: entry.intent,
263 action: entry.action.clone(),
264 },
265 );
266 reactive.push(ReactiveItem {
267 id: entry.id,
268 enabled: entry.enabled.clone(),
269 state: entry.state.clone(),
270 title: entry.title.clone(),
271 });
272
273 Some(NativeMenuNode::Item {
274 id: entry.id,
275 title: strip_title(&entry.title.resolve_now()),
276 key_equiv,
277 enabled: entry.enabled.get(),
278 check,
279 })
280 }
281 }
282}
283
284fn conventional_chord(key: &str) -> NativeKeyEquivalent {
291 NativeKeyEquivalent {
292 key: key.to_string(),
293 command: true,
294 shift: false,
295 alt: false,
296 control: false,
297 }
298}
299
300fn resolve_standard(
313 sm: &StandardMenu,
314 activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
315 shortcut: impl Fn(&str) -> Option<KeyStroke>,
316) -> NativeMenuNode {
317 let mut route = |entry: Option<(&'static str, MenuItemId)>,
321 shortcut_id: Option<&'static str>,
322 fallback: &str|
323 -> Option<StandardRoutedItem> {
324 let (intent, id) = entry?;
325 activations.insert(
326 id,
327 NativeMenuActivation {
328 intent: Some(intent),
329 action: None,
330 },
331 );
332 let key_equiv = match shortcut_id {
339 Some(sid) => shortcut(sid).map(native_key_equiv),
340 None => Some(conventional_chord(fallback)),
341 };
342 Some(StandardRoutedItem { id, key_equiv })
343 };
344
345 let quit_item = route(sm.quit_route(), sm.quit_shortcut_id(), "q");
346 let settings_item = route(sm.settings_route(), sm.settings_shortcut_id(), ",");
349
350 NativeMenuNode::Standard {
351 role: sm.role(),
352 labels: sm.resolve_labels(),
353 quit_item,
354 settings_item,
355 }
356}
357
358fn check_delta(check: NativeCheck) -> MenuItemDelta {
359 MenuItemDelta {
360 check: Some(check),
361 ..Default::default()
362 }
363}
364
365fn tri_to_native(state: CheckState) -> NativeCheck {
366 match state {
367 CheckState::Checked => NativeCheck::On,
368 CheckState::Unchecked => NativeCheck::Off,
369 CheckState::Indeterminate => NativeCheck::Mixed,
370 }
371}
372
373fn strip_title(raw: &str) -> String {
374 parse_mnemonic(raw).stripped
375}
376
377fn native_key_equiv(ks: KeyStroke) -> NativeKeyEquivalent {
390 NativeKeyEquivalent {
391 key: key_to_equiv(ks.key),
392 command: ks.modifiers.command() || ks.modifiers.super_key(),
393 shift: ks.modifiers.shift(),
394 alt: ks.modifiers.alt(),
395 control: ks.modifiers.without(Modifiers::COMMAND).ctrl(),
396 }
397}
398
399fn key_to_equiv(key: Key) -> String {
400 let special = match key {
401 Key::Enter => "\r",
402 Key::Tab => "\t",
403 Key::Space => " ",
404 Key::Escape => "\u{1b}",
405 Key::Backspace => "\u{8}",
406 Key::Delete => "\u{7f}",
407 Key::ArrowUp => "\u{F700}",
408 Key::ArrowDown => "\u{F701}",
409 Key::ArrowLeft => "\u{F702}",
410 Key::ArrowRight => "\u{F703}",
411 Key::Home => "\u{F729}",
412 Key::End => "\u{F72B}",
413 Key::PageUp => "\u{F72C}",
414 Key::PageDown => "\u{F72D}",
415 Key::F1 => "\u{F704}",
416 Key::F2 => "\u{F705}",
417 Key::F3 => "\u{F706}",
418 Key::F4 => "\u{F707}",
419 Key::F5 => "\u{F708}",
420 Key::F6 => "\u{F709}",
421 Key::F7 => "\u{F70A}",
422 Key::F8 => "\u{F70B}",
423 Key::F9 => "\u{F70C}",
424 Key::F10 => "\u{F70D}",
425 Key::F11 => "\u{F70E}",
426 Key::F12 => "\u{F70F}",
427 other => return other.to_char().map(|c| c.to_string()).unwrap_or_default(),
429 };
430 special.to_string()
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use teksilo_i18n::LocalizedString;
437
438 fn labels_of(node: &NativeMenuNode) -> &teksilo_platform::native_menu::StandardLabels {
439 match node {
440 NativeMenuNode::Standard { labels, .. } => labels,
441 _ => panic!("expected a standard menu node"),
442 }
443 }
444
445 fn quit_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
446 match node {
447 NativeMenuNode::Standard { quit_item, .. } => quit_item.as_ref(),
448 _ => panic!("expected a standard menu node"),
449 }
450 }
451
452 fn settings_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
453 match node {
454 NativeMenuNode::Standard { settings_item, .. } => settings_item.as_ref(),
455 _ => panic!("expected a standard menu node"),
456 }
457 }
458
459 fn quit_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
460 quit_of(node).map(|r| r.id)
461 }
462
463 fn settings_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
464 settings_of(node).map(|r| r.id)
465 }
466
467 fn no_shortcuts(_: &str) -> Option<KeyStroke> {
469 None
470 }
471
472 fn only(id: &'static str, ks: KeyStroke) -> impl Fn(&str) -> Option<KeyStroke> {
474 move |asked| (asked == id).then_some(ks)
475 }
476
477 fn chord(item: Option<&StandardRoutedItem>) -> Option<(String, bool, bool)> {
479 item?
480 .key_equiv
481 .as_ref()
482 .map(|k| (k.key.clone(), k.command, k.shift))
483 }
484
485 #[test]
489 fn a_standard_app_menu_has_no_settings_row_by_default() {
490 let mut activations = HashMap::new();
491 let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
492 assert_eq!(settings_item_of(&node), None);
493 }
494
495 #[test]
497 fn a_settings_intent_becomes_a_routed_item_with_an_activation() {
498 let mut activations = HashMap::new();
499 let node = resolve_standard(
500 &StandardMenu::app().settings_intent("app.settings"),
501 &mut activations,
502 no_shortcuts,
503 );
504 let id = settings_item_of(&node).expect("a routed settings carries an item id");
505 assert_eq!(
506 activations.get(&id).map(|a| a.intent),
507 Some(Some("app.settings"))
508 );
509 }
510
511 #[test]
514 fn quit_and_settings_are_routed_under_distinct_ids() {
515 let mut activations = HashMap::new();
516 let node = resolve_standard(
517 &StandardMenu::app()
518 .quit_intent("app.quit")
519 .settings_intent("app.settings"),
520 &mut activations,
521 no_shortcuts,
522 );
523 let quit = quit_item_of(&node).expect("quit id");
524 let settings = settings_item_of(&node).expect("settings id");
525 assert_ne!(quit, settings);
526 assert_eq!(activations.len(), 2);
527 assert_eq!(activations[&quit].intent, Some("app.quit"));
528 assert_eq!(activations[&settings].intent, Some("app.settings"));
529 }
530
531 #[test]
534 fn the_routed_settings_id_is_stable_across_installs() {
535 let menu = StandardMenu::app().settings_intent("app.settings");
536 let mut first = HashMap::new();
537 let mut second = HashMap::new();
538 assert_eq!(
539 settings_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
540 settings_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
541 );
542 }
543
544 #[test]
548 fn the_settings_label_resolves_through_the_widget_layer() {
549 let mut activations = HashMap::new();
550 let node = resolve_standard(
551 &StandardMenu::app().settings(LocalizedString::literal("Réglages…")),
552 &mut activations,
553 no_shortcuts,
554 );
555 assert_eq!(labels_of(&node).settings, "Réglages…");
556 }
557
558 #[test]
562 fn a_standard_app_menu_routes_nothing_by_default() {
563 let mut activations = HashMap::new();
564 let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
565 assert_eq!(quit_item_of(&node), None);
566 assert!(
567 activations.is_empty(),
568 "an unrouted standard menu owns no activation"
569 );
570 }
571
572 #[test]
576 fn a_quit_intent_becomes_a_routed_item_with_an_activation() {
577 let mut activations = HashMap::new();
578 let node = resolve_standard(
579 &StandardMenu::app().quit_intent("app.quit"),
580 &mut activations,
581 no_shortcuts,
582 );
583 let id = quit_item_of(&node).expect("a routed quit carries an item id");
584 let activation = activations
585 .get(&id)
586 .expect("the routed id resolves to an activation");
587 assert_eq!(activation.intent, Some("app.quit"));
588 assert!(
589 activation.action.is_none(),
590 "routing by name only — no closure to run on the side"
591 );
592 }
593
594 #[test]
599 fn the_routed_quit_id_is_stable_across_installs() {
600 let menu = StandardMenu::app().quit_intent("app.quit");
601 let mut first = HashMap::new();
602 let mut second = HashMap::new();
603 assert_eq!(
604 quit_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
605 quit_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
606 );
607 }
608
609 #[test]
613 fn two_app_menus_get_distinct_routed_ids() {
614 let mut activations = HashMap::new();
615 let a = resolve_standard(
616 &StandardMenu::app().quit_intent("app.quit"),
617 &mut activations,
618 no_shortcuts,
619 );
620 let b = resolve_standard(
621 &StandardMenu::app().quit_intent("app.quit"),
622 &mut activations,
623 no_shortcuts,
624 );
625 assert_ne!(quit_item_of(&a), quit_item_of(&b));
626 assert_eq!(activations.len(), 2);
627 }
628
629 #[test]
632 fn routing_leaves_the_localized_labels_alone() {
633 let mut activations = HashMap::new();
634 let node = resolve_standard(
635 &StandardMenu::app()
636 .quit(LocalizedString::literal("Quitter"))
637 .quit_intent("app.quit"),
638 &mut activations,
639 no_shortcuts,
640 );
641 assert_eq!(labels_of(&node).quit, "Quitter");
642 }
643
644 #[test]
650 fn an_unnamed_shortcut_falls_back_to_the_conventional_chord() {
651 let mut activations = HashMap::new();
652 let node = resolve_standard(
653 &StandardMenu::app()
654 .quit_intent("app.quit")
655 .settings_intent("app.settings"),
656 &mut activations,
657 no_shortcuts,
658 );
659 assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
660 assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
661 }
662
663 #[test]
667 fn a_named_shortcut_supplies_the_chord() {
668 let mut activations = HashMap::new();
669 let node = resolve_standard(
670 &StandardMenu::app()
671 .quit_intent("app.quit")
672 .quit_shortcut("app.quit"),
673 &mut activations,
674 only("app.quit", KeyStroke::command(Key::Q)),
675 );
676 assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
677 }
678
679 #[test]
685 fn a_rebound_shortcut_moves_the_rows_chord_with_it() {
686 let mut activations = HashMap::new();
687 let node = resolve_standard(
688 &StandardMenu::app()
689 .quit_intent("app.quit")
690 .quit_shortcut("app.quit"),
691 &mut activations,
692 only("app.quit", KeyStroke::command_shift(Key::Q)),
693 );
694 assert_eq!(
695 chord(quit_of(&node)),
696 Some(("q".into(), true, true)),
697 "the row follows the rebind rather than keeping the convention"
698 );
699 }
700
701 #[test]
706 fn a_named_but_unbound_shortcut_leaves_the_row_chordless() {
707 let mut activations = HashMap::new();
708 let node = resolve_standard(
709 &StandardMenu::app()
710 .quit_intent("app.quit")
711 .quit_shortcut("app.quit"),
712 &mut activations,
713 no_shortcuts,
714 );
715 assert!(quit_of(&node).is_some(), "the row is still there");
716 assert_eq!(chord(quit_of(&node)), None, "it just has no chord");
717 }
718
719 #[test]
721 fn each_row_reads_its_own_shortcut() {
722 let mut activations = HashMap::new();
723 let node = resolve_standard(
724 &StandardMenu::app()
725 .quit_intent("app.quit")
726 .quit_shortcut("app.quit")
727 .settings_intent("app.settings")
728 .settings_shortcut("app.settings"),
729 &mut activations,
730 only("app.settings", KeyStroke::command(Key::Character(','))),
731 );
732 assert_eq!(chord(quit_of(&node)), None, "quit's id resolves to nothing");
733 assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
734 }
735}