use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
day_reactive::tls_slots! {
menu;
static ACTIONS: RefCell<HashMap<u64, Rc<dyn Fn()>>> = RefCell::new(HashMap::new());
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
static APP_MENU_MODEL: RefCell<Vec<day_spec::MenuItem>> = const { RefCell::new(Vec::new()) };
static APP_MENU_IDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
static DOCK_MENU_IDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
static DOCK_MENU_MODEL: RefCell<Vec<day_spec::MenuItem>> = const { RefCell::new(Vec::new()) };
static DOCK_MENU_SHOWN: Cell<bool> = const { Cell::new(false) };
}
pub fn reset_menus() {
ACTIONS.with(|a| a.borrow_mut().clear());
APP_MENU_MODEL.with(|m| m.borrow_mut().clear());
APP_MENU_IDS.with(|i| i.borrow_mut().clear());
}
pub fn rebind_action(from: u64, to: u64) {
if from == 0 || to == 0 || from == to {
return;
}
ACTIONS.with(|m| {
let mut m = m.borrow_mut();
if let Some(f) = m.remove(&from) {
m.insert(to, f);
}
});
}
pub(crate) fn forget_action(id: u64) {
ACTIONS.with(|m| {
m.borrow_mut().remove(&id);
});
}
pub fn register_menu_action(f: Rc<dyn Fn()>) -> u64 {
let id = next_action_id();
ACTIONS.with(|m| m.borrow_mut().insert(id, f));
id
}
pub fn register_scoped_menu_action(f: Rc<dyn Fn()>) -> u64 {
let id = register_menu_action(f);
day_reactive::Scope::current().on_cleanup(move || {
ACTIONS.with(|m| {
m.borrow_mut().remove(&id);
});
});
id
}
pub(crate) fn next_action_id() -> u64 {
NEXT_ID.with(|c| {
let id = c.get();
c.set(id.wrapping_add(1).max(1));
id
})
}
pub fn dispatch_menu_action(id: u64) {
let id = crate::toolbar::resolve_slot(id);
let f = ACTIONS.with(|m| m.borrow().get(&id).cloned());
if let Some(f) = f {
day_reactive::batch(|| f());
return;
}
log::warn!(
"menu action {id} has no handler — the build that registered it was disposed and \
whatever is still wired to it (an app menu, a context menu, a nav-bar action) was not \
updated. The native side needs the NEW id."
);
}
fn same_shape(a: &[day_spec::MenuItem], b: &[day_spec::MenuItem]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| shape_of(x) == shape_of(y))
}
fn shape_of(item: &day_spec::MenuItem) -> day_spec::MenuItem {
let mut i = item.clone();
blank_ids(std::slice::from_mut(&mut i));
i
}
fn blank_ids(items: &mut [day_spec::MenuItem]) {
for item in items {
match item {
day_spec::MenuItem::Action { action, .. } => *action = 0,
day_spec::MenuItem::Submenu { items, .. } => blank_ids(items),
day_spec::MenuItem::Separator => {}
}
}
}
fn rebind_menu(next: &mut [day_spec::MenuItem], prev: &[day_spec::MenuItem]) {
for (new, old) in next.iter_mut().zip(prev) {
match (new, old) {
(
day_spec::MenuItem::Action { action: new_id, .. },
day_spec::MenuItem::Action { action: old_id, .. },
) => {
if new_id != old_id {
rebind_action(*new_id, *old_id);
*new_id = *old_id;
}
}
(
day_spec::MenuItem::Submenu { items: n, .. },
day_spec::MenuItem::Submenu { items: o, .. },
) => rebind_menu(n, o),
_ => {}
}
}
}
pub fn set_app_menu(items: Vec<day_spec::MenuItem>) {
let items = inject_preferences(items);
let mut items = inject_about(items);
let unchanged = APP_MENU_MODEL.with(|m| same_shape(&m.borrow(), &items));
if unchanged {
let installed = APP_MENU_MODEL.with(|m| m.borrow().clone());
rebind_menu(&mut items, &installed);
APP_MENU_MODEL.with(|m| *m.borrow_mut() = items);
return;
}
let new_ids = collect_action_ids(&items);
let durable = [
crate::windows::preferences_action_id(),
crate::windows::about_action_id(),
crate::windows::check_for_updates_action_id(),
crate::windows::new_window_action_id(),
crate::undo_action_id(false),
crate::undo_action_id(true),
crate::edit_action_id(day_spec::EditOp::Cut),
crate::edit_action_id(day_spec::EditOp::Copy),
crate::edit_action_id(day_spec::EditOp::Paste),
crate::edit_action_id(day_spec::EditOp::SelectAll),
];
let stale: Vec<u64> = APP_MENU_IDS.with(|ids| {
ids.borrow()
.iter()
.copied()
.filter(|id| !new_ids.contains(id) && !durable.contains(id))
.collect()
});
ACTIONS.with(|m| {
let mut m = m.borrow_mut();
for id in stale {
m.remove(&id);
}
});
APP_MENU_IDS.with(|ids| *ids.borrow_mut() = new_ids);
APP_MENU_MODEL.with(|m| *m.borrow_mut() = items.clone());
crate::with_tree(|t| t.set_app_menu(items));
}
pub fn set_dock_menu(items: Vec<day_spec::MenuItem>) {
let new_ids = collect_action_ids(&items);
let stale: Vec<u64> = DOCK_MENU_IDS.with(|ids| {
ids.borrow()
.iter()
.copied()
.filter(|id| !new_ids.contains(id))
.collect()
});
ACTIONS.with(|m| {
let mut m = m.borrow_mut();
for id in stale {
m.remove(&id);
}
});
DOCK_MENU_IDS.with(|ids| *ids.borrow_mut() = new_ids);
DOCK_MENU_MODEL.with(|m| *m.borrow_mut() = items);
install_dock_menu();
}
pub(crate) fn install_dock_menu() {
let mut items = crate::shortcuts::shortcut_menu_items();
let app = DOCK_MENU_MODEL.with(|m| m.borrow().clone());
if !items.is_empty() && !app.is_empty() {
items.push(day_spec::MenuItem::Separator);
}
items.extend(app);
if items.is_empty() && !DOCK_MENU_SHOWN.with(|c| c.get()) {
return;
}
DOCK_MENU_SHOWN.with(|c| c.set(!items.is_empty()));
crate::with_tree(|t| t.set_dock_menu(&items));
}
pub fn app_menu_model() -> Vec<day_spec::MenuItem> {
APP_MENU_MODEL.with(|m| m.borrow().clone())
}
pub fn app_menu_installed() -> bool {
APP_MENU_MODEL.with(|m| !m.borrow().is_empty())
}
pub fn has_app_menu() -> bool {
APP_MENU_MODEL.with(|m| !m.borrow().is_empty())
}
pub(crate) fn reinstall_app_menu() {
let items = APP_MENU_MODEL.with(|m| m.borrow().clone());
if !items.is_empty() {
set_app_menu(items);
}
}
fn inject_preferences(mut items: Vec<day_spec::MenuItem>) -> Vec<day_spec::MenuItem> {
let prefs_id = crate::windows::preferences_action_id();
if prefs_id == 0 {
return items;
}
fn rewrite(items: &mut [day_spec::MenuItem], prefs_id: u64) -> bool {
use day_spec::MenuItem as M;
for it in items.iter_mut() {
match it {
M::Action { action, role, .. }
if *role == Some(day_spec::MenuRole::Preferences) =>
{
if *action == 0 {
*action = prefs_id;
}
return true;
}
M::Submenu { items, .. } => {
if rewrite(items, prefs_id) {
return true;
}
}
_ => {}
}
}
false
}
if rewrite(&mut items, prefs_id) {
return items;
}
let item = day_spec::MenuItem::Action {
id: None,
action: prefs_id,
label: String::new(),
shortcut: Some(day_spec::Shortcut::primary(",")),
enabled: true,
checked: None,
role: Some(day_spec::MenuRole::Preferences),
icon: None,
};
if let Some(day_spec::MenuItem::Submenu { items: first, .. }) = items
.iter_mut()
.find(|it| matches!(it, day_spec::MenuItem::Submenu { .. }))
{
first.push(day_spec::MenuItem::Separator);
first.push(item);
} else {
items.push(item);
}
items
}
fn inject_about(mut items: Vec<day_spec::MenuItem>) -> Vec<day_spec::MenuItem> {
use day_spec::{MenuItem as M, MenuRole as R};
let about_id = crate::windows::about_action_id();
let updates_id = crate::windows::check_for_updates_action_id();
if about_id == 0 && updates_id == 0 {
return items;
}
fn rewrite(items: &mut [M], role: R, id: u64) -> bool {
let mut found = false;
for it in items.iter_mut() {
match it {
M::Action {
action, role: r, ..
} if *r == Some(role) => {
if *action == 0 {
*action = id;
}
found = true;
}
M::Submenu { items, .. } => {
found |= rewrite(items, role, id);
}
_ => {}
}
}
found
}
let has_about = rewrite(&mut items, R::About, about_id);
let has_updates = rewrite(&mut items, R::CheckForUpdates, updates_id);
let act = |role: R, action: u64| M::Action {
id: None,
action,
label: String::new(),
shortcut: None,
enabled: true,
checked: None,
role: Some(role),
icon: None,
};
let mut missing = Vec::new();
if about_id != 0 && !has_about {
missing.push(act(R::About, about_id));
}
if updates_id != 0 && !has_updates {
missing.push(act(R::CheckForUpdates, updates_id));
}
if missing.is_empty() {
return items;
}
if let Some(M::Submenu { items: help, .. }) = items.iter_mut().find(|it| {
matches!(it, M::Submenu { role, label, .. }
if *role == Some(day_spec::MenuBarRole::Help) || *label == day_localization::t(day_localization::key::day_help))
}) {
help.extend(missing);
}
items
}
pub(crate) fn collect_ids(items: &[day_spec::MenuItem]) -> Vec<u64> {
collect_action_ids(items)
}
pub(crate) fn forget_actions(ids: &[u64]) {
ACTIONS.with(|m| {
let mut m = m.borrow_mut();
for id in ids {
m.remove(id);
}
});
}
fn collect_action_ids(items: &[day_spec::MenuItem]) -> Vec<u64> {
let mut ids = Vec::new();
fn walk(items: &[day_spec::MenuItem], ids: &mut Vec<u64>) {
for it in items {
match it {
day_spec::MenuItem::Action { action, .. } if *action != 0 => ids.push(*action),
day_spec::MenuItem::Submenu { items, .. } => walk(items, ids),
_ => {}
}
}
}
walk(items, &mut ids);
ids
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum MenuBarStyle {
Macos,
Gnome,
Kde,
Windows,
}
impl MenuBarStyle {
pub fn bar_order(
self,
) -> (
&'static [day_spec::MenuBarRole],
&'static [day_spec::MenuBarRole],
) {
use day_spec::MenuBarRole as B;
const LEADING: &[day_spec::MenuBarRole] = &[B::File, B::Edit, B::View];
match self {
MenuBarStyle::Macos => (LEADING, &[B::Window, B::Help]),
MenuBarStyle::Gnome | MenuBarStyle::Windows => (LEADING, &[B::Help]),
MenuBarStyle::Kde => (LEADING, &[B::Settings, B::Help]),
}
}
pub fn stock(self, role: day_spec::MenuBarRole) -> Option<day_spec::MenuItem> {
use day_spec::{MenuBarRole as B, MenuItem as MI, MenuRole as R};
let act = |r: R| MI::Action {
id: None,
action: match r {
R::About => crate::windows::about_action_id(),
R::CheckForUpdates => crate::windows::check_for_updates_action_id(),
_ => 0,
},
label: String::new(),
shortcut: None,
enabled: true,
checked: None,
role: Some(r),
icon: None,
};
let sub = |key: &str, items: Vec<MI>| {
Some(MI::Submenu {
label: day_localization::t(key),
items,
role: None,
})
};
match role {
B::Edit => sub(
"day_edit",
vec![
act(R::Undo),
act(R::Redo),
MI::Separator,
act(R::Cut),
act(R::Copy),
act(R::Paste),
act(R::Delete),
act(R::SelectAll),
],
),
B::View => sub("day_view", vec![act(R::Fullscreen)]),
B::Help => match self {
MenuBarStyle::Macos => sub("day_help", Vec::new()),
_ => {
let mut items = vec![act(R::About)];
if crate::windows::check_for_updates_action_id() != 0 {
items.push(act(R::CheckForUpdates));
}
sub("day_help", items)
}
},
_ => None,
}
}
}
pub fn standard_menu_bar_for(
style: MenuBarStyle,
app_menus: Vec<day_spec::MenuItem>,
) -> Vec<day_spec::MenuItem> {
let (leading, trailing) = style.bar_order();
standard_menu_bar(app_menus, leading, trailing, |r| style.stock(r))
}
pub fn standard_menu_bar(
app_menus: Vec<day_spec::MenuItem>,
leading: &[day_spec::MenuBarRole],
trailing: &[day_spec::MenuBarRole],
stock: impl Fn(day_spec::MenuBarRole) -> Option<day_spec::MenuItem>,
) -> Vec<day_spec::MenuItem> {
use day_spec::MenuBarRole as R;
let _ = R::File;
let mut claimed: Vec<(R, day_spec::MenuItem)> = Vec::new();
let mut own: Vec<day_spec::MenuItem> = Vec::new();
for item in app_menus {
match &item {
day_spec::MenuItem::Submenu { role: Some(r), .. } => claimed.push((*r, item)),
_ => own.push(item),
}
}
let mut out: Vec<day_spec::MenuItem> = leading
.iter()
.filter_map(|r| take_slot(*r, &mut claimed, &mut own, &stock))
.collect();
let trailing_menus: Vec<day_spec::MenuItem> = trailing
.iter()
.filter_map(|r| take_slot(*r, &mut claimed, &mut own, &stock))
.collect();
out.append(&mut own);
out.extend(trailing_menus);
out.extend(claimed.into_iter().map(|(_, m)| m));
out
}
fn take_slot(
role: day_spec::MenuBarRole,
claimed: &mut Vec<(day_spec::MenuBarRole, day_spec::MenuItem)>,
own: &mut Vec<day_spec::MenuItem>,
stock: &impl Fn(day_spec::MenuBarRole) -> Option<day_spec::MenuItem>,
) -> Option<day_spec::MenuItem> {
if let Some(i) = claimed.iter().position(|(cr, _)| *cr == role) {
return Some(claimed.remove(i).1);
}
if let Some(title) = role_title(role)
&& let Some(i) = own
.iter()
.position(|m| submenu_label(m).is_some_and(|l| menu_titles_match(l, &title)))
{
return Some(own.remove(i));
}
stock(role)
}
fn role_title(role: day_spec::MenuBarRole) -> Option<String> {
use day_spec::MenuBarRole as B;
let key = match role {
B::File => "day_file",
B::Edit => "day_edit",
B::View => "day_view",
B::Window => "day_window",
B::Help => "day_help",
_ => return None,
};
Some(day_localization::t(key))
}
fn submenu_label(item: &day_spec::MenuItem) -> Option<&str> {
match item {
day_spec::MenuItem::Submenu { label, .. } => Some(label),
_ => None,
}
}
fn menu_titles_match(a: &str, b: &str) -> bool {
let norm = |s: &str| s.replace('&', "").trim().to_lowercase();
norm(a) == norm(b)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use day_spec::MenuBarRole as BR;
const MAC_LEADING: &[BR] = &[BR::File, BR::Edit, BR::View];
const MAC_TRAILING: &[BR] = &[BR::Window, BR::Help];
fn sub(label: &str, role: Option<day_spec::MenuBarRole>) -> day_spec::MenuItem {
day_spec::MenuItem::Submenu {
label: label.into(),
items: Vec::new(),
role,
}
}
fn labels(items: &[day_spec::MenuItem]) -> Vec<String> {
items
.iter()
.map(|i| match i {
day_spec::MenuItem::Submenu { label, .. } => label.clone(),
_ => String::new(),
})
.collect()
}
#[test]
fn stock_menus_fill_the_slots_an_app_left_open() {
use day_spec::MenuBarRole as R;
let out = standard_menu_bar(
vec![sub("Go", None), sub("Article", None)],
MAC_LEADING,
MAC_TRAILING,
|r| {
Some(sub(
match r {
R::File => "File",
R::Edit => "Edit",
R::View => "View",
R::Window => "Window",
R::Help => "Help",
_ => "?",
},
Some(r),
))
},
);
assert_eq!(
labels(&out),
["File", "Edit", "View", "Go", "Article", "Window", "Help"]
);
}
#[test]
fn an_untagged_menu_named_like_a_stock_one_takes_its_slot() {
let bar = standard_menu_bar(
vec![sub("File", None), sub("Edit", None), sub("View", None)],
&[BR::File, BR::Edit, BR::View],
&[BR::Window, BR::Help],
|r| match r {
BR::File => None,
BR::Edit => Some(sub("Edit", Some(BR::Edit))),
BR::View => Some(sub("View", Some(BR::View))),
BR::Window => Some(sub("Window", Some(BR::Window))),
_ => Some(sub("Help", Some(BR::Help))),
},
);
assert_eq!(
labels(&bar),
["File", "Edit", "View", "Window", "Help"],
"a hand-built standard menu must not double the stock one"
);
}
#[test]
fn menu_titles_match_across_mnemonics_and_case() {
assert!(menu_titles_match("&View", "View"));
assert!(menu_titles_match("edit ", "Edit"));
assert!(menu_titles_match("Ansicht", "ansicht"));
assert!(!menu_titles_match("Edit", "View"));
}
#[test]
fn a_claimed_slot_replaces_the_stock_menu_in_place() {
use day_spec::MenuBarRole as R;
let out = standard_menu_bar(
vec![sub("My File", Some(R::File)), sub("Go", None)],
MAC_LEADING,
MAC_TRAILING,
|r| Some(sub("stock", Some(r))),
);
let l = labels(&out);
assert_eq!(l[0], "My File", "the app's File sits in the File slot");
assert_eq!(l.iter().filter(|s| *s == "My File").count(), 1);
assert!(!l.contains(&"File".to_string()));
assert_eq!(l[3], "Go", "app menus still follow the leading slots");
}
#[test]
fn each_platform_gets_its_own_bar_order() {
use day_spec::MenuBarRole as R;
let stock = |r: R| {
let label = match r {
R::Edit => "Edit",
R::View => "View",
R::Settings => "Settings",
R::Help => "Help",
R::Window => "Window",
_ => return None,
};
Some(sub(label, Some(r)))
};
let kde = standard_menu_bar(
vec![sub("Go", None)],
&[R::File, R::Edit, R::View],
&[R::Settings, R::Help],
stock,
);
assert_eq!(labels(&kde), ["Edit", "View", "Go", "Settings", "Help"]);
let gnome = standard_menu_bar(
vec![sub("Go", None)],
&[R::File, R::Edit, R::View],
&[R::Help],
stock,
);
assert_eq!(labels(&gnome), ["Edit", "View", "Go", "Help"]);
assert!(!labels(&gnome).contains(&"Window".to_string()));
}
#[test]
fn a_slot_with_no_stock_menu_is_omitted() {
use day_spec::MenuBarRole as R;
let out = standard_menu_bar(vec![sub("Go", None)], MAC_LEADING, MAC_TRAILING, |r| {
(r == R::Edit).then(|| sub("Edit", Some(r)))
});
assert_eq!(labels(&out), ["Edit", "Go"]);
}
#[test]
fn dispatch_runs_the_registered_action_by_id() {
thread_local! { static FIRED: Cell<u32> = const { Cell::new(0) }; }
let id = register_menu_action(Rc::new(|| FIRED.with(|c| c.set(c.get() + 1))));
assert_ne!(
id, 0,
"ids are nonzero so role-only items (id 0) never dispatch"
);
assert_eq!(FIRED.with(Cell::get), 0);
dispatch_menu_action(id);
assert_eq!(FIRED.with(Cell::get), 1, "the closure ran exactly once");
let id2 = register_menu_action(Rc::new(|| {}));
assert_ne!(id, id2);
dispatch_menu_action(0);
dispatch_menu_action(u64::MAX);
assert_eq!(FIRED.with(Cell::get), 1);
}
#[test]
fn scoped_action_is_reclaimed_when_its_scope_dies() {
let scope = day_reactive::Scope::child();
let id = scope.enter(|| register_scoped_menu_action(Rc::new(|| {})));
assert!(
ACTIONS.with(|m| m.borrow().contains_key(&id)),
"registered while the scope is alive"
);
scope.dispose();
assert!(
!ACTIONS.with(|m| m.borrow().contains_key(&id)),
"disposal must reclaim the closure — this is the remount leak"
);
dispatch_menu_action(id); }
}