Fix #1354: the macOS menu bar is modal under dialogs, like shortcuts

Native menu clicks went straight to menus::invoke, so a menu command ran
beneath an open dialog: rotating the canvas under Image Size left the
dialog's stale size to be applied on OK. While a dialog, the unsaved
changes prompt or Camera Raw is open, the native rows are now disabled and
a click that raced the update is dropped; Zoom In/Out, Fit on Screen and
100% stay live, as their shortcuts do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
This commit is contained in:
Claude committed 2026-10-08 20:21:46 +00:00
1 parent 84b76a8c9c
commit 31169ec750
1 file changed
+53 -1
+53 -1
View File
@@ -647,6 +647,17 @@ impl NativeMenu {
}
}
/// Dialogs, the unsaved-changes prompt and Camera Raw are modal, as they are for shortcuts
/// ([`crate::shortcuts::handle`]): while one is open, only the view navigation Photoshop keeps
/// live under a dialog may run from the menu bar. A rotation under Image Size used to leave the
/// dialog's stale size to be applied on OK (#1354).
fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
if app.camera_raw.is_some() {
return false;
}
(app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id)
}
/// Run the native menu clicks that arrived since the last frame, like in-window menu clicks.
pub fn run(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(menu) = app.services.native_menu.as_mut() else { return };
@@ -656,6 +667,10 @@ pub fn run(app: &mut PhotocraftApp, ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
continue;
}
// The rows are disabled while a dialog is open; a click that raced the update is dropped.
if !modal_allows(app, &id) {
continue;
}
if let Err(e) = crate::menus::invoke(app, ctx, &id, serde_json::json!({})) {
app.ui.status = e;
app.ui.status_error = true;
@@ -674,7 +689,11 @@ pub fn sync(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
let lang = crate::i18n::current();
let layout = photocraft_layout(&crate::menus::menu_items(app), lang, &app.session.prefs().interface.language);
let mut items = crate::menus::menu_items(app);
for it in &mut items {
it.enabled &= modal_allows(app, &it.id);
}
let layout = photocraft_layout(&items, lang, &app.session.prefs().interface.language);
let Some(menu) = app.services.native_menu.as_mut() else { return };
if !menu.logged {
for c in &layout.clashes {
@@ -702,6 +721,7 @@ fn state_hash(app: &PhotocraftApp, input: Option<u64>) -> u64 {
}
let ui = &app.ui;
(&ui.recent_files, &ui.workspace, ui.palette_open, ui.transform.is_some(), ui.text_edit.is_some(), ui.dialogs.len()).hash(&mut h);
(app.discard.is_some(), app.camera_raw.is_some()).hash(&mut h);
serde_json::to_string(&(&ui.panels, &ui.extras, &ui.view, ui.theme, ui.tool)).unwrap_or_default().hash(&mut h);
s.prefs().interface.language.hash(&mut h);
h.finish()
@@ -958,6 +978,38 @@ mod tests {
assert_eq!(bar.find("window.panel.layers").unwrap().checked, Some(!before));
}
/// #1354: a dialog is modal for the menu bar too. Its rows are disabled (view navigation
/// aside), and a click that arrives anyway doesn't run under the dialog.
#[test]
fn an_open_dialog_blocks_menu_clicks_except_view_navigation() {
let synced = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let mut app = app(true);
let ctx = egui::Context::default();
crate::menus::invoke(&mut app, &ctx, "image.imageSize", json!({})).unwrap();
assert!(!app.ui.dialogs.is_empty(), "Image Size opened its dialog");
let size = |app: &PhotocraftApp| app.session.active().map(|d| (d.doc.size.width, d.doc.size.height));
let zoom = app.ui.views[0].zoom;
let events = vec![Event::Click("image.imageRotation.90cw".into()), Event::Click("view.zoomIn".into())];
app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: synced.clone(), events })));
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
frame(&ctx, raw, |ui| {
run(&mut app, ui.ctx());
sync(&mut app, ui.ctx());
});
assert_eq!(size(&app), Some((64, 48)), "the rotation didn't run under the dialog");
assert!(app.ui.views[0].zoom > zoom, "Zoom In still works under a dialog");
let bar = synced.borrow().last().cloned().unwrap();
assert!(!bar.find("image.imageRotation.90cw").unwrap().enabled);
assert!(bar.find("view.zoomIn").unwrap().enabled);
// With the dialog closed the rows come back and a click runs.
app.ui.dialogs.clear();
frame(&ctx, egui::RawInput::default(), |ui| sync(&mut app, ui.ctx()));
assert!(synced.borrow().last().unwrap().find("image.imageRotation.90cw").unwrap().enabled);
}
/// With the Mac menu bar the title bar draws no menu titles: each title shows once fewer.
#[test]
fn the_title_bar_hides_its_menus_with_the_mac_menu_bar() {