use std::collections::HashMap;
use teksilo_core::MenuItemId;
use teksilo_core::ObserverHandle;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{Key, Modifiers};
use teksilo_core::shortcut::KeyStroke;
use teksilo_core::signal::Prop;
use teksilo_data::CheckState;
use teksilo_i18n::LocalizedString;
use teksilo_platform::native_menu::{
MenuItemDelta, NativeCheck, NativeKeyEquivalent, NativeMenuActivation, NativeMenuHandle,
NativeMenuNode, NativeMenuSnapshot, StandardMenuRole, StandardRoutedItem,
};
use crate::menu_item::parse_mnemonic;
use super::model::{MenuItemState, MenuModel, MenuNode, StandardMenu};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NativeMenuMode {
#[default]
Off,
Suppress,
Coexist,
}
impl NativeMenuMode {
pub(crate) fn suppresses_in_window(self) -> bool {
cfg!(target_os = "macos") && matches!(self, NativeMenuMode::Suppress)
}
pub(crate) fn installs_native(self) -> bool {
!matches!(self, NativeMenuMode::Off)
}
}
pub(crate) struct NativeMenuBinding {
_observers: Vec<ObserverHandle>,
}
pub(crate) fn install(model: &MenuModel, ctx: &BuildContext) -> Option<NativeMenuBinding> {
let handle = ctx.app_state::<NativeMenuHandle>()?.clone();
let window_id = ctx.window()?.id();
let poster = ctx.poster()?.clone();
let mut activations = HashMap::new();
let mut reactive = Vec::new();
let mut roots: Vec<NativeMenuNode> = {
let nodes = model.nodes();
nodes
.iter()
.filter_map(|n| resolve_node(n, ctx, &mut activations, &mut reactive))
.collect()
};
let has_app = roots.iter().any(|n| {
matches!(
n,
NativeMenuNode::Standard {
role: StandardMenuRole::App,
..
}
)
});
if !has_app {
roots.insert(
0,
NativeMenuNode::Standard {
role: StandardMenuRole::App,
labels: StandardMenu::app().resolve_labels(),
quit_item: None,
settings_item: None,
},
);
}
let snapshot = NativeMenuSnapshot { roots };
handle.set_window_menu(window_id, snapshot, activations, poster);
let mut observers = Vec::new();
for item in reactive {
{
let sig = item.title.to_signal();
let h = handle.clone();
let id = item.id;
observers.push(sig.observe(move |v| {
h.update_item(
id,
MenuItemDelta {
title: Some(strip_title(v)),
..Default::default()
},
);
}));
}
if let Prop::Bound(sig) = item.enabled {
let h = handle.clone();
let id = item.id;
observers.push(sig.observe(move |v| {
h.update_item(
id,
MenuItemDelta {
enabled: Some(*v),
..Default::default()
},
);
}));
}
match item.state {
MenuItemState::Plain => {}
MenuItemState::Check(sig) | MenuItemState::ReflectCheck(sig) => {
let h = handle.clone();
let id = item.id;
observers.push(sig.observe(move |v| {
h.update_item(
id,
check_delta(if *v {
NativeCheck::On
} else {
NativeCheck::Off
}),
);
}));
}
MenuItemState::TriCheck(sig) => {
let h = handle.clone();
let id = item.id;
observers.push(sig.observe(move |v| {
h.update_item(id, check_delta(tri_to_native(*v)));
}));
}
MenuItemState::Radio { value, selected } => {
let h = handle.clone();
let id = item.id;
observers.push(selected.observe(move |sel| {
let check = if *sel == value {
NativeCheck::On
} else {
NativeCheck::Off
};
h.update_item(id, check_delta(check));
}));
}
}
}
Some(NativeMenuBinding {
_observers: observers,
})
}
struct ReactiveItem {
id: MenuItemId,
enabled: Prop<bool>,
state: MenuItemState,
title: LocalizedString,
}
fn resolve_node(
node: &MenuNode,
ctx: &BuildContext,
activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
reactive: &mut Vec<ReactiveItem>,
) -> Option<NativeMenuNode> {
match node {
MenuNode::Separator => Some(NativeMenuNode::Separator),
MenuNode::Standard(sm) => Some(resolve_standard(sm, activations, |id| {
ctx.effective_shortcut(id).and_then(|eff| eff.primary)
})),
MenuNode::Submenu {
title, children, ..
} => Some(NativeMenuNode::Submenu {
title: strip_title(&title.resolve_now()),
children: children
.iter()
.filter_map(|n| resolve_node(n, ctx, activations, reactive))
.collect(),
}),
MenuNode::Item(entry) if !entry.visible.get() => None,
MenuNode::Item(entry) => {
let check = match &entry.state {
MenuItemState::Plain => NativeCheck::None,
MenuItemState::Check(s) | MenuItemState::ReflectCheck(s) => {
if s.get() {
NativeCheck::On
} else {
NativeCheck::Off
}
}
MenuItemState::TriCheck(s) => tri_to_native(s.get()),
MenuItemState::Radio { value, selected } => {
if selected.get() == *value {
NativeCheck::On
} else {
NativeCheck::Off
}
}
};
let key_equiv = entry
.shortcut_id
.and_then(|id| ctx.effective_shortcut(id).and_then(|eff| eff.primary))
.map(native_key_equiv);
activations.insert(
entry.id,
NativeMenuActivation {
intent: entry.intent,
action: entry.action.clone(),
},
);
reactive.push(ReactiveItem {
id: entry.id,
enabled: entry.enabled.clone(),
state: entry.state.clone(),
title: entry.title.clone(),
});
Some(NativeMenuNode::Item {
id: entry.id,
title: strip_title(&entry.title.resolve_now()),
key_equiv,
enabled: entry.enabled.get(),
check,
})
}
}
}
fn conventional_chord(key: &str) -> NativeKeyEquivalent {
NativeKeyEquivalent {
key: key.to_string(),
command: true,
shift: false,
alt: false,
control: false,
}
}
fn resolve_standard(
sm: &StandardMenu,
activations: &mut HashMap<MenuItemId, NativeMenuActivation>,
shortcut: impl Fn(&str) -> Option<KeyStroke>,
) -> NativeMenuNode {
let mut route = |entry: Option<(&'static str, MenuItemId)>,
shortcut_id: Option<&'static str>,
fallback: &str|
-> Option<StandardRoutedItem> {
let (intent, id) = entry?;
activations.insert(
id,
NativeMenuActivation {
intent: Some(intent),
action: None,
},
);
let key_equiv = match shortcut_id {
Some(sid) => shortcut(sid).map(native_key_equiv),
None => Some(conventional_chord(fallback)),
};
Some(StandardRoutedItem { id, key_equiv })
};
let quit_item = route(sm.quit_route(), sm.quit_shortcut_id(), "q");
let settings_item = route(sm.settings_route(), sm.settings_shortcut_id(), ",");
NativeMenuNode::Standard {
role: sm.role(),
labels: sm.resolve_labels(),
quit_item,
settings_item,
}
}
fn check_delta(check: NativeCheck) -> MenuItemDelta {
MenuItemDelta {
check: Some(check),
..Default::default()
}
}
fn tri_to_native(state: CheckState) -> NativeCheck {
match state {
CheckState::Checked => NativeCheck::On,
CheckState::Unchecked => NativeCheck::Off,
CheckState::Indeterminate => NativeCheck::Mixed,
}
}
fn strip_title(raw: &str) -> String {
parse_mnemonic(raw).stripped
}
fn native_key_equiv(ks: KeyStroke) -> NativeKeyEquivalent {
NativeKeyEquivalent {
key: key_to_equiv(ks.key),
command: ks.modifiers.command() || ks.modifiers.super_key(),
shift: ks.modifiers.shift(),
alt: ks.modifiers.alt(),
control: ks.modifiers.without(Modifiers::COMMAND).ctrl(),
}
}
fn key_to_equiv(key: Key) -> String {
let special = match key {
Key::Enter => "\r",
Key::Tab => "\t",
Key::Space => " ",
Key::Escape => "\u{1b}",
Key::Backspace => "\u{8}",
Key::Delete => "\u{7f}",
Key::ArrowUp => "\u{F700}",
Key::ArrowDown => "\u{F701}",
Key::ArrowLeft => "\u{F702}",
Key::ArrowRight => "\u{F703}",
Key::Home => "\u{F729}",
Key::End => "\u{F72B}",
Key::PageUp => "\u{F72C}",
Key::PageDown => "\u{F72D}",
Key::F1 => "\u{F704}",
Key::F2 => "\u{F705}",
Key::F3 => "\u{F706}",
Key::F4 => "\u{F707}",
Key::F5 => "\u{F708}",
Key::F6 => "\u{F709}",
Key::F7 => "\u{F70A}",
Key::F8 => "\u{F70B}",
Key::F9 => "\u{F70C}",
Key::F10 => "\u{F70D}",
Key::F11 => "\u{F70E}",
Key::F12 => "\u{F70F}",
other => return other.to_char().map(|c| c.to_string()).unwrap_or_default(),
};
special.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_i18n::LocalizedString;
fn labels_of(node: &NativeMenuNode) -> &teksilo_platform::native_menu::StandardLabels {
match node {
NativeMenuNode::Standard { labels, .. } => labels,
_ => panic!("expected a standard menu node"),
}
}
fn quit_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
match node {
NativeMenuNode::Standard { quit_item, .. } => quit_item.as_ref(),
_ => panic!("expected a standard menu node"),
}
}
fn settings_of(node: &NativeMenuNode) -> Option<&StandardRoutedItem> {
match node {
NativeMenuNode::Standard { settings_item, .. } => settings_item.as_ref(),
_ => panic!("expected a standard menu node"),
}
}
fn quit_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
quit_of(node).map(|r| r.id)
}
fn settings_item_of(node: &NativeMenuNode) -> Option<MenuItemId> {
settings_of(node).map(|r| r.id)
}
fn no_shortcuts(_: &str) -> Option<KeyStroke> {
None
}
fn only(id: &'static str, ks: KeyStroke) -> impl Fn(&str) -> Option<KeyStroke> {
move |asked| (asked == id).then_some(ks)
}
fn chord(item: Option<&StandardRoutedItem>) -> Option<(String, bool, bool)> {
item?
.key_equiv
.as_ref()
.map(|k| (k.key.clone(), k.command, k.shift))
}
#[test]
fn a_standard_app_menu_has_no_settings_row_by_default() {
let mut activations = HashMap::new();
let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
assert_eq!(settings_item_of(&node), None);
}
#[test]
fn a_settings_intent_becomes_a_routed_item_with_an_activation() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app().settings_intent("app.settings"),
&mut activations,
no_shortcuts,
);
let id = settings_item_of(&node).expect("a routed settings carries an item id");
assert_eq!(
activations.get(&id).map(|a| a.intent),
Some(Some("app.settings"))
);
}
#[test]
fn quit_and_settings_are_routed_under_distinct_ids() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit_intent("app.quit")
.settings_intent("app.settings"),
&mut activations,
no_shortcuts,
);
let quit = quit_item_of(&node).expect("quit id");
let settings = settings_item_of(&node).expect("settings id");
assert_ne!(quit, settings);
assert_eq!(activations.len(), 2);
assert_eq!(activations[&quit].intent, Some("app.quit"));
assert_eq!(activations[&settings].intent, Some("app.settings"));
}
#[test]
fn the_routed_settings_id_is_stable_across_installs() {
let menu = StandardMenu::app().settings_intent("app.settings");
let mut first = HashMap::new();
let mut second = HashMap::new();
assert_eq!(
settings_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
settings_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
);
}
#[test]
fn the_settings_label_resolves_through_the_widget_layer() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app().settings(LocalizedString::literal("Réglages…")),
&mut activations,
no_shortcuts,
);
assert_eq!(labels_of(&node).settings, "Réglages…");
}
#[test]
fn a_standard_app_menu_routes_nothing_by_default() {
let mut activations = HashMap::new();
let node = resolve_standard(&StandardMenu::app(), &mut activations, no_shortcuts);
assert_eq!(quit_item_of(&node), None);
assert!(
activations.is_empty(),
"an unrouted standard menu owns no activation"
);
}
#[test]
fn a_quit_intent_becomes_a_routed_item_with_an_activation() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app().quit_intent("app.quit"),
&mut activations,
no_shortcuts,
);
let id = quit_item_of(&node).expect("a routed quit carries an item id");
let activation = activations
.get(&id)
.expect("the routed id resolves to an activation");
assert_eq!(activation.intent, Some("app.quit"));
assert!(
activation.action.is_none(),
"routing by name only — no closure to run on the side"
);
}
#[test]
fn the_routed_quit_id_is_stable_across_installs() {
let menu = StandardMenu::app().quit_intent("app.quit");
let mut first = HashMap::new();
let mut second = HashMap::new();
assert_eq!(
quit_item_of(&resolve_standard(&menu, &mut first, no_shortcuts)),
quit_item_of(&resolve_standard(&menu, &mut second, no_shortcuts)),
);
}
#[test]
fn two_app_menus_get_distinct_routed_ids() {
let mut activations = HashMap::new();
let a = resolve_standard(
&StandardMenu::app().quit_intent("app.quit"),
&mut activations,
no_shortcuts,
);
let b = resolve_standard(
&StandardMenu::app().quit_intent("app.quit"),
&mut activations,
no_shortcuts,
);
assert_ne!(quit_item_of(&a), quit_item_of(&b));
assert_eq!(activations.len(), 2);
}
#[test]
fn routing_leaves_the_localized_labels_alone() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit(LocalizedString::literal("Quitter"))
.quit_intent("app.quit"),
&mut activations,
no_shortcuts,
);
assert_eq!(labels_of(&node).quit, "Quitter");
}
#[test]
fn an_unnamed_shortcut_falls_back_to_the_conventional_chord() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit_intent("app.quit")
.settings_intent("app.settings"),
&mut activations,
no_shortcuts,
);
assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
}
#[test]
fn a_named_shortcut_supplies_the_chord() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit_intent("app.quit")
.quit_shortcut("app.quit"),
&mut activations,
only("app.quit", KeyStroke::command(Key::Q)),
);
assert_eq!(chord(quit_of(&node)), Some(("q".into(), true, false)));
}
#[test]
fn a_rebound_shortcut_moves_the_rows_chord_with_it() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit_intent("app.quit")
.quit_shortcut("app.quit"),
&mut activations,
only("app.quit", KeyStroke::command_shift(Key::Q)),
);
assert_eq!(
chord(quit_of(&node)),
Some(("q".into(), true, true)),
"the row follows the rebind rather than keeping the convention"
);
}
#[test]
fn a_named_but_unbound_shortcut_leaves_the_row_chordless() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit_intent("app.quit")
.quit_shortcut("app.quit"),
&mut activations,
no_shortcuts,
);
assert!(quit_of(&node).is_some(), "the row is still there");
assert_eq!(chord(quit_of(&node)), None, "it just has no chord");
}
#[test]
fn each_row_reads_its_own_shortcut() {
let mut activations = HashMap::new();
let node = resolve_standard(
&StandardMenu::app()
.quit_intent("app.quit")
.quit_shortcut("app.quit")
.settings_intent("app.settings")
.settings_shortcut("app.settings"),
&mut activations,
only("app.settings", KeyStroke::command(Key::Character(','))),
);
assert_eq!(chord(quit_of(&node)), None, "quit's id resolves to nothing");
assert_eq!(chord(settings_of(&node)), Some((",".into(), true, false)));
}
}