Skip to main content

day_core/
menu.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Menu action dispatch (§ menus). The model ([`day_spec::MenuItem`]) is toolkit-neutral and carries
5//! only ids for its actions; the closures live here, keyed by id. A backend fires
6//! `Event::MenuAction(id)` when a native item is chosen; the event pump routes it to
7//! [`dispatch_menu_action`], which runs the app's closure. Ids are process-unique and monotonic.
8
9use 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    /// The app menu as last installed (post-injection). The dayscript `menu:` step
18    /// matches against it, and late `register_preferences` re-forwards it.
19    static APP_MENU_MODEL: RefCell<Vec<day_spec::MenuItem>> = const { RefCell::new(Vec::new()) };
20    /// The action ids the current app menu registered, so replacing the menu (the reactive
21    /// re-lower on a locale change) can drop the stale closures without touching context
22    /// menus, which share the `ACTIONS` map.
23    static APP_MENU_IDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
24    /// The dispatch ids the installed Dock menu holds, swept on the next install.
25    static DOCK_MENU_IDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
26    /// The app's own Dock menu entries, kept so a launch-time install can put them back after
27    /// the shortcuts.
28    static DOCK_MENU_MODEL: RefCell<Vec<day_spec::MenuItem>> = const { RefCell::new(Vec::new()) };
29    /// Whether the toolkit currently holds a non-empty Dock menu.
30    static DOCK_MENU_SHOWN: Cell<bool> = const { Cell::new(false) };
31}
32
33/// Forget every menu action and the last-installed app menu: a re-mount (docs/settings.md
34/// "Appearance on Android"). The closures a previous mount registered capture that mount's
35/// reactive graph, which has just been disposed; keeping them would leave the menu bar invoking
36/// a dead tree.
37pub 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
43/// Register an app closure for a menu item and return its dispatch id (nonzero). The `day-pieces`
44/// menu builder calls this while lowering a menu tree to the [`day_spec::MenuItem`] model.
45/// Move the closure registered under `from` onto `to`, dropping `from`.
46///
47/// Used when an app-menu install turns out to be the same menu with new closures: the native
48/// side keeps dispatching the ids it already has, and this points them at the current handlers.
49/// (Toolbars never need it: their items dispatch through stable slot ids, see
50/// `day_core::toolbar`.)
51pub 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
63/// Drop the closure registered under `id`: a toolbar item's, once no contribution carries it.
64pub(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
76/// Register an app closure whose lifetime is tied to the current scope: the id is dropped
77/// from the dispatch map when that scope is disposed. This is the registrar for closures
78/// owned by a piece build (context menus, nav-row menus, nav `bar_action`), which are
79/// re-registered on every rebuild; without the cleanup each remount leaks the previous
80/// build's closure (and whatever app state it captured) for the process lifetime.
81///
82/// The app menu and toolbars stay on plain [`register_menu_action`]: their ids are managed
83/// by shape-rebinding ([`rebind_action`]) and toolbar slots plus explicit sweeps, and a scope cleanup firing
84/// after a rebind would remove an id the platform still dispatches.
85pub 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
95/// The next dispatch id (nonzero, monotonic). Toolbar value callbacks draw from this same
96/// counter (docs/toolbars.md) so a toolbar id can never collide with a menu id.
97pub(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
105/// Run the closure registered for `id` (no-op if none). Called by the event pump on
106/// `Event::MenuAction`. Runs inside a reactive batch so multiple signal writes coalesce.
107pub fn dispatch_menu_action(id: u64) {
108    // A toolbar button or pull-down entry carries its bar's stable slot id (docs/toolbars.md).
109    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    // An id with no closure means something native is still wired to an action whose scope has
116    // been disposed: a menu or a nav-bar button that draws normally and does nothing when
117    // pressed. Silent before, and undiagnosable from the outside: the press produces no event,
118    // no log line, no visible change.
119    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
126/// Set the application menu (menu bar / app-bar overflow / iPad main menu). Retains the
127/// model (post-injection, [`app_menu_model`]), injects the auto Preferences item when one
128/// is registered (docs/windows.md), drops the previous app menu's action closures (context
129/// menus share the map and are untouched), and forwards to the backend.
130/// Whether two menu models describe the same menu (labels, shortcuts, roles, enablement and
131/// nesting), ignoring the action ids, which are re-registered on every build.
132fn 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
152/// Move `next`'s closures onto the ids `prev` already published to the platform.
153fn 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    // The same concern the toolbar has (docs/toolbars.md): an app declares its menu inside the
179    // page build, so every route change
180    // re-installs the same menu with freshly registered closures. Rebuilding the native menu bar
181    // for that is churn at best, and on macOS it closes a menu the user has open, so a menu
182    // that differs only in its action ids rebinds onto the ids the platform already holds.
183    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    // The prefs/new-window/undo dispatch ids are registered outside menu lowering (by
193    // `day::register_*` and `undo_action_id`) and outlive any menu install; never sweep them.
194    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
224/// Install the app's Dock menu (docs/menus.md "Dock menu"): `items` go above the app's
225/// `[[shortcuts]]` and the system's own items when the user right-clicks the Dock icon
226/// (`Cap::DockMenu`, macOS). An empty vec leaves only the shortcuts. Replacing the menu drops the
227/// previous install's action closures.
228pub 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
248/// Send the Dock menu to the toolkit: the app's launcher shortcuts first, as the platform's own
249/// apps list their quick actions, then the app's `dock_menu` entries. Called at launch, so the
250/// shortcuts are there before the app installs anything, and on every `set_dock_menu`.
251pub(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    // Nothing to show and nothing shown: leave the toolkit alone (most apps never have a menu).
259    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
266/// The app menu as last installed (post-injection). The dayscript `menu:` step walks it to
267/// resolve an item's dispatch id; backends re-read it on late preferences registration.
268pub fn app_menu_model() -> Vec<day_spec::MenuItem> {
269    APP_MENU_MODEL.with(|m| m.borrow().clone())
270}
271
272/// Whether any app menu was installed (an empty model = the backend default menu is up).
273pub fn app_menu_installed() -> bool {
274    APP_MENU_MODEL.with(|m| !m.borrow().is_empty())
275}
276
277/// Re-forward the retained model through the injection pass: the self-heal for
278/// `register_preferences` running after `app_menu` (docs/menus.md ordering note).
279/// Did the app install a menu model of its own (`app_menu`)? `false` means it is running on the
280/// backend's default menu bar, which the backend builds before `root()` runs, so a backend that
281/// puts registration-dependent items there (Settings…, File ▸ New Window) has to rebuild it once
282/// the app's registrations are in. day-toolkit-appkit does exactly that.
283pub 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
294/// Inject the auto Settings…/Preferences item (docs/windows.md): when a preferences piece
295/// is registered and the model carries an inert `role(Preferences)` item (id 0), rewrite
296/// its id to the registered action; when the model has no Preferences item at all, append
297/// `separator + item` to the first top-level submenu (the File menu by convention). An
298/// app-supplied `.action` (nonzero id) always wins. Backends give the item its platform
299/// placement (macOS hoists it into the App menu) and label fallback.
300fn 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        // Empty label: each backend falls back to its localized role label
334        // ("Settings…" on macOS, "Preferences" elsewhere).
335        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
354/// Inject the About and Check for Updates… commands (docs/settings.md): an inert
355/// `role(About)` / `role(CheckForUpdates)` item (id 0) is rewritten to the registered action,
356/// so an app that placed the role itself keeps its placement. When the model has no About at
357/// all but an About is registered, About (and the updater command, when registered) are
358/// appended to the app's Help menu if it has one; otherwise the stock Help menu
359/// ([`MenuBarStyle::stock`]) supplies them, and macOS hoists both into the App menu whichever
360/// way they arrive.
361fn 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    // Rewrite inert role items in place; report whether each role appears at all.
369    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        // Empty label: each backend falls back to its localized role label.
395        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    // Only an app-owned Help menu takes them here; without one the stock Help menu does.
413    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
422/// The dispatch ids a menu model holds (status items sweep theirs when replaced).
423pub(crate) fn collect_ids(items: &[day_spec::MenuItem]) -> Vec<u64> {
424    collect_action_ids(items)
425}
426
427/// Drop the closures behind `ids`: a menu that held them was replaced or removed.
428pub(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/// Arrange an app's top-level menus into the platform's standard bar, filling every standard
453/// slot the app did not claim with the backend's stock menu.
454///
455/// Which desktop's menu-bar conventions a backend follows. A toolkit picks the style of the
456/// platform it is native to, not the one it happens to be compiled on.
457#[derive(Clone, Copy, Debug, PartialEq, Eq)]
458#[non_exhaustive]
459pub enum MenuBarStyle {
460    /// macOS: an app menu, and a Window menu the toolkit installs natively.
461    Macos,
462    /// GNOME / GTK on Linux.
463    Gnome,
464    /// KDE Plasma / Qt on Linux.
465    Kde,
466    /// Windows.
467    Windows,
468}
469
470impl MenuBarStyle {
471    /// The standard slots before and after the app's own menus on this desktop.
472    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            // Only macOS has a Window menu; the Linux and Windows shells own window management.
482            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    /// This desktop's stock menu for a slot, as a pure model: every entry is a `MenuRole`, so
489    /// each backend renders it with its own native command and localized label and nothing here
490    /// is toolkit- or app-specific.
491    ///
492    /// `File` is always the app's own. `Window` is `None` even on macOS because the toolkit
493    /// installs it natively (only AppKit can append the live window list). `Settings` is `None`
494    /// for now everywhere: Preferences is placed before a backend sees the model
495    /// (`inject_preferences`), so a stock Settings menu would be a second copy of it.
496    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        // About and Check for Updates… dispatch to the registered Day actions when the app
499        // registered them (docs/settings.md); every other role keeps the backend's native
500        // command (action 0).
501        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            // macOS keeps About in the app menu; the other desktops keep it in Help. An empty
538            // Help menu still earns its place on macOS, where AppKit fills it with the help search.
539            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
554/// Assemble the bar for a desktop's conventions: [`standard_menu_bar`] with that style's slot
555/// order and stock menus.
556pub 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
564/// `leading` are the slots before the app's own menus and `trailing` the ones after; each
565/// backend passes its platform's bar (macOS trails Window and Help; KDE trails Settings and
566/// Help; GNOME and Windows trail Help alone). `stock` returns the backend's house version of a
567/// slot, or `None` where that platform has no such menu. Menus the app tagged with
568/// [`day_spec::MenuBarRole`] replace the stock one in place, so an app customizes a standard menu by
569/// claiming it rather than by rebuilding the whole bar.
570pub 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    // A role the platform does not know about still belongs on the bar rather than vanishing.
598    out.extend(claimed.into_iter().map(|(_, m)| m));
599    out
600}
601
602/// Fill one bar slot: the app's role-tagged menu if it claimed the slot, else its own menu that
603/// IS this standard menu by title, else the platform's stock one.
604///
605/// The title fallback is what keeps a bar from showing the same menu twice. Tagging a submenu
606/// with a [`day_spec::MenuBarRole`] is how an app claims a slot, but an app that builds
607/// its own "Edit" (the natural thing to write, and what the showcase did) would otherwise get
608/// the stock Edit in the slot and its own further along the bar. An untagged submenu whose title
609/// is the stock menu's title is that menu, so it takes the slot instead of being appended.
610fn 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    // Match against the slot's localized name, not the stock menu's label: it is the same
620    // string where a stock menu exists, and it still identifies the slot where none does. That
621    // second case is File: no platform ships a stock File, yet an app's hand-built File belongs
622    // in the File slot rather than adrift after View.
623    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
633/// The slot's standard name in the current locale, the same catalog key a stock menu is labeled
634/// from, so an app's "Edit" matches in English and its "Édition" matches under `--locale fr`.
635fn 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
655/// Do two menu titles name the same menu? Compared case-insensitively, without surrounding space
656/// and without the `&` mnemonic markers GTK and Windows carry: `&View` and `View` are one menu.
657/// Both titles come from the same catalog in the same locale at the same moment, so a localized
658/// bar compares localized titles and still matches.
659fn 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    /// Every stock slot appears, in bar order, with the app's own menus between View and Window.
692    #[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    /// An app that builds a standard menu by hand (an "Edit" submenu with no `bar_role`) gets
720    /// one Edit menu, in the Edit slot, not the stock one plus its own further along the bar.
721    /// This is the duplicate the showcase's macOS bar showed: Showcase, Edit, View,
722    /// File, Edit, View, Window, Help.
723    #[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                // File is always the app's own; Window/Help are the platform's.
731                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    /// The title match ignores case, surrounding space, and the `&` mnemonics GTK/Windows carry.
746    #[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    /// A claimed slot replaces the stock menu in place: it does not also get the stock one,
755    /// and it does not slide into the app-menu run.
756    #[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    /// The bar order is the backend's, not one hardcoded shape: KDE trails Settings and Help,
773    /// GNOME and Windows trail Help alone, and neither grows a macOS Window menu.
774    #[test]
775    fn each_platform_gets_its_own_bar_order() {
776        use day_spec::MenuBarRole as R;
777        // File is the app's own everywhere; no backend ships a stock one.
778        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    /// A backend with no stock menu for a slot has no such menu.
808    #[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        // A second, distinct action gets a distinct id and doesn't fire the first.
831        let id2 = register_menu_action(Rc::new(|| {}));
832        assert_ne!(id, id2);
833
834        // Unknown / zero ids are silent no-ops (role items, stale ids).
835        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); // stale native dispatch after teardown: silent no-op
854    }
855}