macOS menu bar: menus keep working after a language change; key equivalents follow the shortcuts (#1540)

* macOS menu bar: keep working after a rebuild; key equivalents follow the shortcuts in effect

* macOS menu bar: the shortcut test sets its key with window.setShortcut
This commit is contained in:
aiongg
2026-10-22 01:18:41 -04:00
committed by GitHub
parent 438d7b6d79
commit 496b1899a2
3 changed files with 55 additions and 18 deletions
+1
View File
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.
- **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change.
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
+37 -15
View File
@@ -27,6 +27,9 @@ pub struct NativeMenu {
shown_for: (String, bool),
/// File › Open Recent, rebuilt when the list of recent files changes.
recent: Option<RecentMenu>,
/// The commands with key equivalents and the shortcut each was given: the menu is rebuilt
/// when one changes, so a shortcut moved to another command leaves its old item.
keys: Vec<(String, Option<String>)>,
}
struct RecentMenu {
@@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String {
menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label))
}
/// A command's default shortcut, as the in-window menus show it.
fn shortcut(id: &str) -> Option<&'static str> {
menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut))
/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it.
fn shortcut(app: &DesignApp, id: &str) -> Option<String> {
menus::shortcut_of(app, id)
}
impl NativeMenu {
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
// A click in the menu bar reaches the app with no input over the window, so nothing would
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
// muda keeps the first handler for the life of the process, so this channel serves every
// menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
}));
Self::build_bar(app, events)
}
/// The menu bar for the current language and settings, fed by `events`.
fn build_bar(app: &mut DesignApp, events: Receiver<MenuEvent>) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@@ -75,7 +85,7 @@ impl NativeMenu {
for entry in menus::MAC_APP_MENU {
let _ = match *entry {
AppMenuItem::Cmd { id, label, command } => {
let sc = shortcut(command).and_then(accel);
let sc = shortcut(app, command).as_deref().and_then(accel);
let item = MenuItem::with_id(id, menu_title(app, label), true, sc);
let appended = app_menu.append(&item);
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
@@ -96,15 +106,19 @@ impl NativeMenu {
}
menu.init_for_nsapp();
app.native_menu = true;
app.native_shortcuts = items
.values()
.filter_map(|(id, p, _)| {
let sc = shortcut(id)?;
(p.is_null() && accel(sc).is_some()).then(|| id.clone())
})
.collect();
let keys: Vec<(String, Option<String>)> =
items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect();
app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect();
let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None });
Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent }
Self {
_menu: menu,
items,
events,
last_refresh: 0.0,
shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)),
recent,
keys,
}
}
/// Put the current recent files at the top of File › Open Recent when they changed.
@@ -153,13 +167,21 @@ impl NativeMenu {
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 {
*self = Self::install(app, ctx);
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
}
let now = designcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
// A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the
// menu bar before the window sees the key, so the bar must carry the new keys.
if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) {
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
return;
}
self.refresh_recent(app);
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
@@ -199,10 +221,10 @@ fn build(
}
// Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes.
Item::Cmd { id, .. } if !menus::shown(app, id) => {}
Item::Cmd { label, id, params, shortcut } => {
Item::Cmd { label, id, params, .. } => {
*counter += 1;
let mid = format!("dc{counter}");
let sc = if params.is_null() { shortcut.and_then(accel) } else { None };
let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None };
let label = menu_title(app, label);
let handle = if menus::checked(app, id, params).is_some() {
let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc);
+17 -3
View File
@@ -3081,9 +3081,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
}
}
if let Some((id, exact)) = fired {
// The native menu's accelerator fires only for the shortcut's own key and modifiers; the
// Plus key standing in for = never reaches it.
if exact && app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
// The native menu bar carries the shortcuts in effect, the user's included, and its
// accelerator fires only for the shortcut's own key and modifiers; the Plus key standing
// in for = never reaches it.
if exact && app.native_shortcuts.contains(&id) {
continue; // The native menu handles it.
}
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
@@ -4506,6 +4507,19 @@ mod tests {
let z = app.view().unwrap().zoom;
assert_eq!(z > z0, zooms_in, "native menu, {k:?} {m:?}: {z0} -> {z}");
}
// A shortcut the user set runs here until the native menu bar carries it as its key
// equivalent; then only the bar runs it, once.
run_ui(&mut app, "window.setShortcut", &json!({"id": "view.zoomOut", "shortcut": "Cmd+9"})).unwrap().unwrap();
for (native, zooms_out) in [(false, true), (true, false)] {
if native {
app.native_shortcuts.insert("view.zoomOut".to_string());
}
let z0 = app.view().unwrap().zoom;
frame(&mut app, key(egui::Key::Num9, egui::Modifiers::COMMAND));
frame(&mut app, vec![]);
let z = app.view().unwrap().zoom;
assert_eq!(z < z0, zooms_out, "native {native}: {z0} -> {z}");
}
}
#[test]