From b10b37c14bf94e2e293473246b6418edcf27739f Mon Sep 17 00:00:00 2001 From: Brennan Stehling <277419+brennanMKE@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:22:28 -0700 Subject: [PATCH] macOS: use the native menu bar instead of the in-window menus (#887) (#894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS: use the native menu bar instead of the in-window menus (#887) On macOS the menus move from the title bar to the menu bar at the top of the screen, built with muda from the same rows as the in-window bar (menu_items). Linux, Windows and the web keep the in-window menus, and so does macOS with --in-window-menus or PHOTOCRAFT_IN_WINDOW_MENUS=1. The Mac layout (native_menu.rs, platform-free and tested everywhere): - a PhotoCraft app menu: About (from Help), Settings (Edit › Preferences), Services, Hide, Hide Others, Show All, and Quit, which is still file.exit so the unsaved-changes prompt runs; - a Window menu AppKit knows (Minimize, Zoom, Bring All to Front, the window list) and a Help menu with the system search field; - PhotoCraft keeps Photoshop's ⌘H (Extras), ⌘M (Curves), ⌘W (Close) and ⌘, (Hide Layers): Hide and Settings have no shortcut, Minimize is ⌃⌘M. Key equivalents are handed back to egui as the key presses they were, so shortcuts run through shortcuts::handle exactly as before: text fields keep ⌘C/⌘V/⌘Z, dialogs and Camera Raw stay modal. A click runs the command like an in-window click. The menu is updated only when state changes, in place, and rebuilt only when its structure does. On every platform: - menu rows show the shortcut that runs them: 12 live items (Undo, Redo, Cut, Copy, Paste, Hide Layers, …) showed none; - Image › Mode, View › Proof Setup, Proof Colors, Gamut Warning and Isolate Layers stay check items with no document open, so opening one doesn't change the menu's structure. Co-Authored-By: Claude Opus 5.5 (1M context) * Native menu: test it against AppKit; fix the Window/Help menus and ⌘R routing tests/mac_menu_appkit.rs (harness = false, main thread, like the tablet crate's monitor test) installs the real muda menu and reads AppKit's menu bar back: the PhotoCraft app menu, Quit ⌘Q, no Exit in File, the Window and Help menus registered with NSApp, and Layer › New › Layer… enabled in place (same NSMenu) once a document opens. It then sends a real ⌘R key-down through -[NSMenu performKeyEquivalent:], which must reach egui as ⌘R and run View › Rulers once, and chooses Rulers by click and by ↩ in the open menu, which must run as clicks. It found two bugs, fixed here: - Window and Help were never registered: muda looks the submenus up in the installed main menu, so set_as_*_menu_for_nsapp must come after init_for_nsapp. The Window menu didn't list windows. - AppKit matched ⌘R to Filter › Lens Correction (⇧⌘R), and the menu handed egui the matched item's shortcut, so ⌘R ran Lens Correction. Key equivalents now go back with the modifiers actually held. Also a kittest: the title bar draws no menu titles with the Mac menu bar. Co-Authored-By: Claude Opus 5.5 (1M context) * Mac app menu: Language and Appearance, Hide on ⌃⌘H (aligned with VectorCraft) App settings live in the app menu, as on a Mac and as VectorCraft does: - Language ▸ after Settings: Auto, then each UI language in its own name, the interface.language preference checked; choosing one runs prefs.set (unknown codes are refused). - Appearance ▸: the themes (now check items, in-window too) and Next Theme move from Window › Theme to the app menu. - Hide PhotoCraft takes ⌃⌘H when ⌘H is View › Extras, as Photoshop does (Minimize already takes ⌃⌘M). The other Craft apps use the same app menu, Window menu and --in-window-menus / _IN_WINDOW_MENUS opt-out. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: bflatastic --- Cargo.lock | 40 +- apps/photocraft/Cargo.toml | 12 + apps/photocraft/src/mac_menu.rs | 359 +++++++++ apps/photocraft/src/main.rs | 15 +- apps/photocraft/tests/mac_menu_appkit.rs | 171 ++++ crates/ui-egui/src/analysis_ui.rs | 3 +- crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/src/menus.rs | 28 +- crates/ui-egui/src/native_menu.rs | 982 +++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 3 +- 10 files changed, 1614 insertions(+), 8 deletions(-) create mode 100644 apps/photocraft/src/mac_menu.rs create mode 100644 apps/photocraft/tests/mac_menu_appkit.rs create mode 100644 crates/ui-egui/src/native_menu.rs diff --git a/Cargo.lock b/Cargo.lock index 97e10ed4..b4c13406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1025,6 +1025,15 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -2451,6 +2460,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2707,6 +2726,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "png 0.18.1", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" @@ -3263,7 +3300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3428,6 +3465,7 @@ dependencies = [ "egui", "fmv-macos-events", "log", + "muda", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 219db34a..ed0d683d 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -52,6 +52,9 @@ fmv-macos-events = "=0.1.0" objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +# The macOS menu bar (mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK +# with default features off). Pinned exactly: review the diff before bumping. +muda = { version = "=0.21.1", default-features = false } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] @@ -62,6 +65,15 @@ x11rb = { version = "=0.13.2", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] winsafe = { version = "=0.0.29", default-features = false, features = ["user"] } +# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSGraphicsContext", "NSMenu", "NSMenuItem", "NSResponder"] } + +[[test]] +name = "mac_menu_appkit" +harness = false + [build-dependencies] # Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/photocraft/src/mac_menu.rs b/apps/photocraft/src/mac_menu.rs new file mode 100644 index 00000000..a0f911a1 --- /dev/null +++ b/apps/photocraft/src/mac_menu.rs @@ -0,0 +1,359 @@ +//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK on +//! macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in +//! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s. +//! +//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window) +//! gets `id#2` for its second copy, as muda ids must be unique. +//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything +//! else (labels, enabled, checked) is updated in place. +//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key +//! equivalents go back to egui as the key press that was made (the event's modifiers, not the +//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply. +//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H. + +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, channel}; + +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType}; +use photocraft_ui_egui::native_menu::{self, Backend, Chord, Event, MenuBar, MenuRole, Node, Standard}; + +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +/// One native item. +struct Entry { + handle: Handle, + /// The command id (several entries can share one). + command: String, + chord: Option, + label: String, + enabled: bool, +} + +/// How an item was chosen, read from AppKit's current event while it calls the handler. +struct Chosen { + id: String, + /// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧. + key: bool, + mods: [bool; 4], +} + +pub struct MacMenu { + menu: Option, + /// By muda id. + entries: HashMap, + rx: Receiver, + structure: u64, +} + +impl MacMenu { + pub fn new(ctx: &egui::Context) -> MacMenu { + let (tx, rx) = channel(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + // AppKit calls this on the main thread, from the event that chose the item: a key + // equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu). + let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent()); + let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown); + let flags = event.as_ref().map(|ev| ev.modifierFlags()).unwrap_or(NSEventModifierFlags::empty()); + let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift] + .map(|m| flags.contains(m)); + let _ = tx.send(Chosen { id: e.id.0, key, mods }); + // Wake egui, so the item runs now rather than on the next mouse move. + repaint.request_repaint(); + })); + MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 } + } + + fn build(&mut self, bar: &MenuBar) { + if let Some(old) = self.menu.take() { + old.remove_for_nsapp(); + } + self.entries.clear(); + let menu = Menu::new(); + let mut special = Vec::new(); + for m in &bar.menus { + let sub = Submenu::new(escape(&m.title), true); + self.append(&sub, &m.children); + let _ = menu.append(&sub); + if matches!(m.role, MenuRole::Window | MenuRole::Help) { + special.push((m.role, sub)); + } + } + menu.init_for_nsapp(); + // Only once the menu is NSApp's: muda looks the submenus up in the installed main menu. + for (role, sub) in special { + match role { + MenuRole::Window => sub.set_as_windows_menu_for_nsapp(), + _ => sub.set_as_help_menu_for_nsapp(), + } + } + self.menu = Some(menu); + self.structure = native_menu::structure_key(bar); + } + + fn append(&mut self, sub: &Submenu, nodes: &[Node]) { + for n in nodes { + match n { + Node::Separator => { + let _ = sub.append(&PredefinedMenuItem::separator()); + } + Node::Submenu { label, children, .. } => { + let s = Submenu::new(escape(label), !children.is_empty()); + self.append(&s, children); + let _ = sub.append(&s); + } + Node::Standard(item) => { + let p = match item { + Standard::Services => PredefinedMenuItem::services(None), + Standard::HideOthers => PredefinedMenuItem::hide_others(None), + Standard::ShowAll => PredefinedMenuItem::show_all(None), + Standard::Minimize => PredefinedMenuItem::minimize(None), + Standard::Zoom => PredefinedMenuItem::maximize(Some(photocraft_ui_egui::i18n::t("Zoom"))), + Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None), + Standard::CloseWindow => PredefinedMenuItem::close_window(None), + }; + let _ = sub.append(&p); + } + Node::Item(it) => { + let id = self.unique_id(&it.id); + let chord = it.shortcut.as_deref().and_then(Chord::parse); + let accel = chord.as_ref().filter(|c| c.native_ok()).and_then(accelerator); + let handle = match it.checked { + Some(c) => { + let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel); + let _ = sub.append(&h); + Handle::Check(h) + } + None => { + let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel); + let _ = sub.append(&h); + Handle::Plain(h) + } + }; + self.entries.insert(id, Entry { handle, command: it.id.clone(), chord, label: it.label.clone(), enabled: it.enabled }); + } + } + } + } + + /// muda ids must be unique: the second `edit.keyboardShortcuts` becomes `…#2`. + fn unique_id(&self, id: &str) -> String { + let mut unique = id.to_string(); + let mut n = 2; + while self.entries.contains_key(&unique) { + unique = format!("{id}#{n}"); + n += 1; + } + unique + } +} + +impl Backend for MacMenu { + fn sync(&mut self, bar: &MenuBar) { + if self.menu.is_none() || native_menu::structure_key(bar) != self.structure { + self.build(bar); + return; + } + let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect(); + for e in self.entries.values_mut() { + let Some(it) = items.get(e.command.as_str()) else { continue }; + if e.label != it.label { + let text = escape(&it.label); + match &e.handle { + Handle::Plain(h) => h.set_text(text), + Handle::Check(h) => h.set_text(text), + } + e.label = it.label.clone(); + } + if e.enabled != it.enabled { + match &e.handle { + Handle::Plain(h) => h.set_enabled(it.enabled), + Handle::Check(h) => h.set_enabled(it.enabled), + } + e.enabled = it.enabled; + } + // Compare with AppKit's own state: muda toggles a check item when it's clicked. + if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked) + && h.is_checked() != c + { + h.set_checked(c); + } + } + } + + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(chosen) = self.rx.try_recv() { + // Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled. + let Some(e) = self.entries.get(&chosen.id) else { continue }; + if e.command == native_menu::HIDE { + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).hide(None); + } + continue; + } + // A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open + // menu (no ⌘ or ⌃, and the item's shortcut needs one). The key is the item's (AppKit + // matched on it); the modifiers are the ones held. + let [cmd, ctrl, alt, shift] = chosen.mods; + let key = + e.chord.as_ref().filter(|c| chosen.key && (cmd || ctrl || !(c.cmd || c.ctrl))).map(|c| Chord { cmd, ctrl, alt, shift, key: c.key.clone() }); + out.push(match key { + Some(chord) if e.command != native_menu::MINIMIZE => Event::Key(chord), + _ => Event::Click(e.command.clone()), + }); + } + out + } +} + +/// Install the menu bar now (in eframe's creator closure, so winit's default menu doesn't stay). +pub fn install(ctx: &egui::Context, app: &photocraft_ui_egui::PhotocraftApp) -> Option { + let language = &app.session.prefs().interface.language; + let lang = photocraft_ui_egui::i18n::Lang::from_pref(language); + let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(app), lang, language).bar; + // Never crash for a menu: if AppKit refuses, keep the in-window menus. + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut menu = MacMenu::new(ctx); + menu.sync(&bar); + menu + })); + match built { + Ok(menu) => Some(native_menu::NativeMenu::new(Box::new(menu))), + Err(_) => { + log::warn!("couldn't install the macOS menu bar; using the in-window menus"); + None + } + } +} + +/// `&` marks a mnemonic in muda labels. +fn escape(s: &str) -> String { + s.replace('&', "&&") +} + +/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together. +fn accelerator(c: &Chord) -> Option { + let mut mods = Modifiers::empty(); + for (on, m) in [(c.cmd, Modifiers::META), (c.ctrl, Modifiers::CONTROL), (c.alt, Modifiers::ALT), (c.shift, Modifiers::SHIFT)] { + if on { + mods |= m; + } + } + const LETTERS: [Code; 26] = [ + Code::KeyA, + Code::KeyB, + Code::KeyC, + Code::KeyD, + Code::KeyE, + Code::KeyF, + Code::KeyG, + Code::KeyH, + Code::KeyI, + Code::KeyJ, + Code::KeyK, + Code::KeyL, + Code::KeyM, + Code::KeyN, + Code::KeyO, + Code::KeyP, + Code::KeyQ, + Code::KeyR, + Code::KeyS, + Code::KeyT, + Code::KeyU, + Code::KeyV, + Code::KeyW, + Code::KeyX, + Code::KeyY, + Code::KeyZ, + ]; + const DIGITS: [Code; 10] = + [Code::Digit0, Code::Digit1, Code::Digit2, Code::Digit3, Code::Digit4, Code::Digit5, Code::Digit6, Code::Digit7, Code::Digit8, Code::Digit9]; + const F: [Code; 24] = [ + Code::F1, + Code::F2, + Code::F3, + Code::F4, + Code::F5, + Code::F6, + Code::F7, + Code::F8, + Code::F9, + Code::F10, + Code::F11, + Code::F12, + Code::F13, + Code::F14, + Code::F15, + Code::F16, + Code::F17, + Code::F18, + Code::F19, + Code::F20, + Code::F21, + Code::F22, + Code::F23, + Code::F24, + ]; + let k = c.key.as_str(); + let mut chars = k.chars(); + let code = match (chars.next(), chars.next()) { + (Some(ch), None) if ch.is_ascii_alphabetic() => *LETTERS.get((ch.to_ascii_uppercase() as usize).checked_sub('A' as usize)?)?, + (Some(ch), None) if ch.is_ascii_digit() => *DIGITS.get((ch as usize).checked_sub('0' as usize)?)?, + _ if c.is_function_key() => *F.get(k.get(1..)?.parse::().ok()?.checked_sub(1)?)?, + _ => match k { + "[" => Code::BracketLeft, + "]" => Code::BracketRight, + "\\" => Code::Backslash, + "/" => Code::Slash, + "=" => Code::Equal, + "-" => Code::Minus, + "'" => Code::Quote, + ";" => Code::Semicolon, + "," => Code::Comma, + "." => Code::Period, + "`" => Code::Backquote, + "Delete" | "Backspace" => Code::Backspace, + "Enter" if !mods.is_empty() => Code::Enter, + "Tab" if !mods.is_empty() => Code::Tab, + "Space" if !mods.is_empty() => Code::Space, + _ => return None, + }, + }; + Some(Accelerator::new(mods, code)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every shortcut the Mac menu shows becomes a key equivalent (or stays egui's, for bare keys). + #[test] + fn every_menu_shortcut_maps_to_a_key_equivalent() { + let app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(&app), photocraft_ui_egui::i18n::Lang::EN, "auto").bar; + let mut native = 0; + for it in bar.items() { + let Some(c) = it.shortcut.as_deref().and_then(Chord::parse) else { continue }; + if c.native_ok() { + assert!(accelerator(&c).is_some(), "{} ({}) has no key equivalent", c.portable(), it.id); + native += 1; + } + } + assert!(native > 70, "{native} key equivalents"); + } + + #[test] + fn control_and_command_stay_distinct() { + let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap(); + let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap(); + assert_ne!(a, b); + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 39915f72..acdb76ac 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -2,12 +2,15 @@ //! //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] -//! [--automation-write-root ] [--safe-gpu] [files…]` +//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [files…]` //! //! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window //! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that //! crashes inside the driver also falls back by itself next time (see `gpu_startup`). //! +//! `--in-window-menus` (or `PHOTOCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on +//! macOS instead of the macOS menu bar (`mac_menu`). +//! //! `--control ` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server. //! The first line must authenticate; subsequent request lines get reply lines. //! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods. @@ -26,6 +29,8 @@ mod crash_guard; mod cursor; mod gpu_startup; #[cfg(target_os = "macos")] +mod mac_menu; +#[cfg(target_os = "macos")] mod mac_window; // Pure logic is tested on every platform; only Linux runs the check. #[cfg(any(target_os = "linux", test))] @@ -131,6 +136,7 @@ fn main() -> eframe::Result { let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from); let mut files = Vec::new(); let mut safe_gpu = false; + let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { @@ -146,6 +152,7 @@ fn main() -> eframe::Result { "--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from), "--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from), "--safe-gpu" => safe_gpu = true, + "--in-window-menus" => in_window_menus = true, "--version" => { println!("photocraft {}", photocraft_engine::build_info::long_version()); return Ok(()); @@ -360,7 +367,13 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] { app.services.os_events = Some(apple_events.connect(&cc.egui_ctx)); + // The macOS menu bar, installed now so winit's default menu doesn't stay up. + if !in_window_menus { + app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app); + } } + #[cfg(not(target_os = "macos"))] + let _ = in_window_menus; // Where file drags and drops are (winit 0.30 doesn't say). app.services.cursor_pos = cursor::service(cc); // Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above diff --git a/apps/photocraft/tests/mac_menu_appkit.rs b/apps/photocraft/tests/mac_menu_appkit.rs new file mode 100644 index 00000000..d60d1ed6 --- /dev/null +++ b/apps/photocraft/tests/mac_menu_appkit.rs @@ -0,0 +1,171 @@ +//! The macOS menu bar end to end, on the main thread (`harness = false`): install it through the +//! real muda backend (`src/mac_menu.rs`), read AppKit's menu bar back, open a document and see the +//! items update in place, then choose items the two ways AppKit can: a key equivalent (a real ⌘R +//! key-down through `-[NSMenu performKeyEquivalent:]`) must reach egui as the key press it was, and +//! a click (`-[NSMenu performActionForItemAtIndex:]`) must run as a click, also when the click is +//! ↩ inside the open menu. + +// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its +// unit tests compile here too without a harness to run them). +#[cfg(target_os = "macos")] +#[path = "../src/mac_menu.rs"] +#[allow(unused_imports, dead_code)] +mod mac_menu; + +#[cfg(target_os = "macos")] +fn main() { + use objc2::MainThreadMarker; + use objc2::rc::Retained; + use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags, NSEventSubtype, NSEventType, NSMenu}; + use objc2_foundation::{NSPoint, NSString}; + use photocraft_ui_egui::PhotocraftApp; + use photocraft_ui_egui::native_menu; + use serde_json::json; + + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); + let nsapp = NSApplication::sharedApplication(mtm); + nsapp.finishLaunching(); + + let titles = |menu: &NSMenu| -> Vec { + (0..menu.numberOfItems()).filter_map(|i| menu.itemAtIndex(i)).filter(|it| !it.isSeparatorItem()).map(|it| it.title().to_string()).collect() + }; + let submenu = |menu: &NSMenu, title: &str| -> Retained { + (0..menu.numberOfItems()) + .filter_map(|i| menu.itemAtIndex(i)) + .find(|it| it.title().to_string() == title) + .and_then(|it| it.submenu()) + .unwrap_or_else(|| panic!("no {title} submenu in {:?}", titles(menu))) + }; + let index = |menu: &NSMenu, title: &str| -> isize { + (0..menu.numberOfItems()) + .find(|&i| menu.itemAtIndex(i).is_some_and(|it| it.title().to_string() == title)) + .unwrap_or_else(|| panic!("no {title} in {:?}", titles(menu))) + }; + let main_menu = || nsapp.mainMenu().expect("a main menu"); + // Make `event` AppKit's current event, as the run loop does before dispatching it. + let dequeue = |event: &NSEvent| { + nsapp.postEvent_atStart(event, true); + let mode = NSString::from_str("kCFRunLoopDefaultMode"); + let got = nsapp.nextEventMatchingMask_untilDate_inMode_dequeue(NSEventMask::Any, None, &mode, true).expect("the posted event"); + assert_eq!(got.r#type(), event.r#type()); + got + }; + let key_down = |chars: &str, code: u16, flags: NSEventModifierFlags| { + let s = NSString::from_str(chars); + NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode( + NSEventType::KeyDown, + NSPoint::new(0.0, 0.0), + flags, + 0.0, + 0, + None, + &s, + &s, + false, + code, + ) + .expect("a key event") + }; + let other = || { + NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2( + NSEventType::ApplicationDefined, + NSPoint::new(0.0, 0.0), + NSEventModifierFlags::empty(), + 0.0, + 0, + None, + NSEventSubtype::ApplicationActivated.0, + 0, + 0, + ) + .expect("an application-defined event") + }; + + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default()); + let ctx = egui::Context::default(); + let frame = |app: &mut PhotocraftApp, raw: egui::RawInput| { + let mut out = ctx.run_ui(raw, |ui| { + native_menu::run(app, ui.ctx()); + photocraft_ui_egui::shortcuts::handle(app, ui.ctx()); + native_menu::sync(app, ui.ctx()); + }); + out.textures_delta.clear(); + }; + app.services.native_menu = mac_menu::install(&ctx, &app); + assert!(app.services.native_menu.is_some(), "the menu bar installed"); + + // The Mac layout, as AppKit has it. + let bar = main_menu(); + let tops: Vec = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect(); + assert_eq!(tops, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]); + let app_menu = submenu(&bar, "PhotoCraft"); + let names = titles(&app_menu); + for want in ["About PhotoCraft", "Settings", "Language", "Appearance", "Hide PhotoCraft", "Quit PhotoCraft"] { + assert!(names.iter().any(|n| n == want), "{want} missing from the app menu: {names:?}"); + } + let quit = app_menu.itemAtIndex(index(&app_menu, "Quit PhotoCraft")).expect("Quit"); + assert_eq!(quit.keyEquivalent().to_string(), "q"); + assert_eq!(quit.keyEquivalentModifierMask(), NSEventModifierFlags::Command); + assert!(!titles(&submenu(&bar, "File")).iter().any(|n| n == "Exit"), "Exit moved to the app menu as Quit"); + assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu"); + assert_eq!(nsapp.helpMenu().map(|m| m.title().to_string()).as_deref(), Some("Help"), "AppKit knows the Help menu"); + + // Opening a document enables Layer › New › Layer… in place: same NSMenu, no rebuild. + let new_layer = |bar: &NSMenu| { + let new = submenu(&submenu(bar, "Layer"), "New"); + new.itemAtIndex(index(&new, "Layer…")).expect("Layer…").isEnabled() + }; + assert!(!new_layer(&bar), "no document: New Layer is disabled"); + app.run("file.new", json!({"width": 64, "height": 48})).expect("a document"); + frame(&mut app, egui::RawInput::default()); + assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "opening a document updated the menu, not rebuilt it"); + assert!(new_layer(&main_menu()), "with a document New Layer is enabled"); + + // A key equivalent: ⌘R reaches egui as ⌘R and toggles View › Rulers once. + let rulers = app.ui.extras.rulers; + let press = dequeue(&key_down("r", 15, NSEventModifierFlags::Command)); + assert!(main_menu().performKeyEquivalent(&press), "⌘R is View › Rulers' key equivalent"); + let mut raw = egui::RawInput::default(); + if let Some(m) = app.services.native_menu.as_mut() { + m.raw_input(&mut raw); + } + let keys: Vec<_> = raw + .events + .iter() + .filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None }) + .collect(); + assert_eq!(keys, [(egui::Key::R, true, true), (egui::Key::R, false, true)], "the key press, then its release"); + frame(&mut app, raw); + let ran: Vec = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect(); + assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched"); + assert_eq!(app.ui.extras.rulers, !rulers, "⌘R toggled Rulers once"); + + // A click: View › Rulers chosen with the mouse runs the command. + let view = submenu(&main_menu(), "View"); + let at = index(&view, "Rulers"); + dequeue(&other()); + view.performActionForItemAtIndex(at); + let mut raw = egui::RawInput::default(); + if let Some(m) = app.services.native_menu.as_mut() { + m.raw_input(&mut raw); + } + assert!(raw.events.is_empty(), "a click is no key press: {:?}", raw.events); + frame(&mut app, raw); + assert_eq!(app.ui.extras.rulers, rulers, "the click toggled Rulers back"); + + // ↩ inside the open menu is a key-down without ⌘: still a click. + dequeue(&key_down("\r", 36, NSEventModifierFlags::empty())); + view.performActionForItemAtIndex(at); + let mut raw = egui::RawInput::default(); + if let Some(m) = app.services.native_menu.as_mut() { + m.raw_input(&mut raw); + } + assert!(raw.events.is_empty(), "↩ in the menu is a click: {:?}", raw.events); + frame(&mut app, raw); + assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it"); + + println!("mac_menu_appkit: ok"); +} + +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index f8791cdf..15f72c33 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -82,8 +82,9 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option { "image.analysis.countTool" => Some(app.ui.tool == Tool::Count), _ => { // View › Proof Setup simulations: checked while that proof is shown. + // A check item with no document too: an item's kind never changes (native menus). let kind = id.strip_prefix("view.proofSetup.").and_then(photocraft_engine::proof_sim::ProofKind::from_id)?; - let d = app.session.active()?; + let Some(d) = app.session.active() else { return Some(false) }; let pv = app.session.color.proof(d.doc.id); Some(pv.enabled && pv.setup.kind == kind) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0cfcb94b..c6edf19a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -85,6 +85,7 @@ pub mod menus; pub mod monitor_status; pub mod move_mods; pub mod move_ui; +pub mod native_menu; pub mod new_doc_ui; pub mod notices; mod opacity_keys; @@ -289,6 +290,8 @@ pub struct Services { /// Reads the displays and their ICC profiles in the background (desktop macOS; see /// `monitor_status`). Without one, the canvas uses the profile chosen in Color Settings, or sRGB. pub read_displays: Option, + /// The macOS menu bar, when the desktop app installed one; the in-window menus are hidden then. + pub native_menu: Option, } pub struct PhotocraftApp { @@ -980,6 +983,7 @@ impl eframe::App for PhotocraftApp { self.checker = None; } self.drain_control(ctx); + native_menu::run(self, ctx); if self.ui.text_edit.is_some() && !self.ui.tool.is_type() { type_tool::commit(self); } @@ -1029,6 +1033,10 @@ impl eframe::App for PhotocraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + // Native menu key equivalents become the key presses they were (see `native_menu`). + if let Some(menu) = self.services.native_menu.as_mut() { + menu.raw_input(raw_input); + } shortcuts::clipboard_keys(ctx, ctx.text_edit_focused() || self.ui.text_edit.is_some(), raw_input); raw_input.events.extend(self.take_synthetic_step()); } @@ -1092,6 +1100,7 @@ impl eframe::App for PhotocraftApp { // A device lost while drawing this frame: switch to the CPU canvas before the next one. gpu_status::check(self, &ctx); self.automation_input = false; + native_menu::sync(self, &ctx); self.perf.frame(gpu_canvas::now_ms() - t0); // Synthetic input is injected one press/release step per frame: keep frames coming until // the queue is empty, then release control replies waiting on it. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c063da7a..c371518e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -286,6 +286,14 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: app.set_theme(ctx, k); Ok(Value::Null) } + // The macOS app menu's Language (`native_menu::language_node`). + i if i.starts_with(crate::native_menu::LANGUAGE_PREFIX) => { + let code = &i[crate::native_menu::LANGUAGE_PREFIX.len()..]; + if code != "auto" && !crate::i18n::Lang::all().any(|l| l.code() == code) { + return Err(format!("unknown UI language `{code}`")); + } + app.run("prefs.set", json!({"values": {"interface.language": code}})) + } "edit.search" => { app.ui.palette_open = !app.ui.palette_open; Ok(Value::Null) @@ -523,9 +531,15 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { } } +/// Image › Mode items that carry a checkmark. +const MODE_CHECKS: [&str; 11] = ["rgb", "grayscale", "cmyk", "lab", "multichannel", "indexedColor", "bitmap", "duotone", "bits8", "bits16", "bits32"]; + /// Is a UI-level panel toggle currently on (for checkmarks)? fn checked(app: &PhotocraftApp, id: &str) -> Option { use photocraft_doc::{ColorMode, SampleType}; + if let Some(name) = id.strip_prefix("window.theme.").filter(|n| *n != "toggle") { + return Some(crate::theme::ThemeKind::from_name(name) == Some(app.ui.theme)); + } if let Some(c) = crate::view_cmds::checked(app, id) { return Some(c); } @@ -544,11 +558,13 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { if let Some(alias) = panel_alias(id) { return checked(app, alias); } + // Check items stay check items with no document open (`Some(false)`, not `None`): a native + // menu can't change an item's kind in place, so a change would rebuild the whole menu. if id == "select.isolateLayers" { - return Some(!app.session.active()?.isolated_layers.is_empty()); + return Some(app.session.active().is_some_and(|d| !d.isolated_layers.is_empty())); } if id == "view.proofColors" || id == "view.gamutWarning" { - let d = app.session.active()?; + let Some(d) = app.session.active() else { return Some(false) }; let pv = app.session.color.proof(d.doc.id); return Some(if id == "view.proofColors" { pv.enabled } else { pv.gamut_warning }); } @@ -557,7 +573,9 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { return Some(app.ui.workspace == if want.is_empty() { "Essentials" } else { want }); } if let Some(m) = id.strip_prefix("image.mode.") { - let d = &app.session.active()?.doc; + let Some(d) = app.session.active().map(|s| &s.doc) else { + return MODE_CHECKS.contains(&m).then_some(false); + }; return match m { "rgb" => Some(d.mode == ColorMode::Rgb), "grayscale" => Some(d.mode == ColorMode::Grayscale), @@ -639,7 +657,9 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { id: id.to_string(), label: label.to_string(), path: path.iter().map(|s| s.to_string()).collect(), - shortcut: sc.map(Into::into), + // A live item shows the shortcut that runs it ([`crate::shortcut_dispatch::bindings`] + // prefers the command's own over the catalogue's): Undo ⌘Z, Copy ⌘C, Hide Layers ⌘,. + shortcut: if known(id) { crate::shortcuts::default_shortcut(id) } else { sc.map(Into::into) }, enabled: known(id) && is_enabled(app, id), checked: checked(app, id), color: None, diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs new file mode 100644 index 00000000..e38a40c6 --- /dev/null +++ b/crates/ui-egui/src/native_menu.rs @@ -0,0 +1,982 @@ +//! The macOS menu bar: the same menus as the in-window bar ([`crate::menus::menu_items`]), laid +//! out the way a Mac app's are and handed to a platform [`Backend`] (the desktop app's muda one). +//! +//! Pure and platform-free, so it is tested everywhere and builds for the web; only the backend +//! touches AppKit. What the layout does ([`mac_layout`]): +//! - an **app menu** named PhotoCraft: About (from Help), Settings (Edit › Preferences), Language, +//! Appearance (Window › Theme), Services, Hide, Hide Others, Show All and Quit (File › Exit, +//! still the app's own `file.exit` so the unsaved-changes prompt runs). The other Craft apps +//! use the same app menu; +//! - **Window** gets Minimize, Zoom and Bring All to Front, and **Help** the system search field; +//! - **clashes** with the system's keys (⌘H, ⌘M, ⌘W, ⌘,) are resolved in PhotoCraft's favour, and +//! reported; Hide and Minimize move to ⌃⌘H and ⌃⌘M, as Photoshop does. +//! +//! **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, +//! which would skip PhotoCraft's key rules (text fields keep ⌘C/⌘V/⌘Z, dialogs and Camera Raw are +//! modal, Liquify has its own ⌘Z). So the backend reports a key equivalent as [`Event::Key`], and +//! [`NativeMenu::raw_input`] feeds it to egui as the key press it was: the shortcut runs through +//! [`crate::shortcuts::handle`] exactly as without a native menu. A click is [`Event::Click`] and +//! runs the command like an in-window menu click. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use egui::{Key, Modifiers}; + +use crate::PhotocraftApp; +use crate::i18n::{Lang, tr, tr_id}; +use crate::menus::MenuItem; + +pub const APP_NAME: &str = "PhotoCraft"; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MenuBar { + pub menus: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Menu { + pub title: String, + pub role: MenuRole, + pub children: Vec, +} + +/// What a top-level menu is for. macOS treats the app, Window and Help menus specially. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum MenuRole { + #[default] + Normal, + App, + Window, + Help, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Item(Item), + Separator, + Submenu { + label: String, + children: Vec, + role: Option, + }, + /// An item AppKit provides and runs itself (Services, Zoom, …). + Standard(Standard), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Item { + /// The command id (`file.open`), or one of [`HIDE`] / [`MINIMIZE`]. + pub id: String, + pub label: String, + /// `Cmd+Shift+Z` form; `Ctrl` is the real Control key. + pub shortcut: Option, + pub enabled: bool, + /// `Some` for check items. + pub checked: Option, + pub role: Option, +} + +/// Items macOS expects in fixed places. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ItemRole { + About, + Settings, + Quit, + Hide, + Minimize, + /// Window › Theme and Next Theme, which become the app menu's Appearance submenu. + Appearance, + /// The app menu's Language submenu (its labels are final, never translated by id). + Language, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Standard { + Services, + HideOthers, + ShowAll, + Minimize, + Zoom, + BringAllToFront, + CloseWindow, +} + +/// Hide PhotoCraft: a custom item (the predefined one always takes ⌘H, which is View › Extras). +pub const HIDE: &str = "app.hide"; +/// Minimize: a custom item (the predefined one always takes ⌘M, which is Curves). +pub const MINIMIZE: &str = "window.minimize"; + +impl Item { + fn new(id: &str, label: &str) -> Item { + Item { id: id.into(), label: label.into(), shortcut: None, enabled: true, checked: None, role: None } + } +} + +impl MenuBar { + /// Every item, depth first. + pub fn items(&self) -> Vec<&Item> { + fn walk<'a>(nodes: &'a [Node], out: &mut Vec<&'a Item>) { + for n in nodes { + match n { + Node::Item(it) => out.push(it), + Node::Submenu { children, .. } => walk(children, out), + Node::Separator | Node::Standard(_) => {} + } + } + } + let mut out = Vec::new(); + for m in &self.menus { + walk(&m.children, &mut out); + } + out + } + + pub fn find(&self, id: &str) -> Option<&Item> { + self.items().into_iter().find(|it| it.id == id) + } + + pub fn menu(&self, role: MenuRole) -> Option<&Menu> { + self.menus.iter().find(|m| m.role == role) + } + + fn tag_item(&mut self, id: &str, role: ItemRole) { + fn walk(nodes: &mut [Node], id: &str, role: ItemRole) { + for n in nodes { + match n { + Node::Item(it) if it.id == id => it.role = Some(role), + Node::Submenu { children, .. } => walk(children, id, role), + _ => {} + } + } + } + for m in &mut self.menus { + walk(&mut m.children, id, role); + } + } + + fn tag_submenu(&mut self, top: &str, name: &str, role: ItemRole) { + let Some(menu) = self.menus.iter_mut().find(|m| m.title == top) else { return }; + for n in &mut menu.children { + if let Node::Submenu { label, role: r, .. } = n + && label == name + { + *r = Some(role); + } + } + } + + fn tag_menu(&mut self, title: &str, role: MenuRole) { + if let Some(m) = self.menus.iter_mut().find(|m| m.title == title) { + m.role = role; + } + } +} + +/// The menus as a tree, from the flat rows [`crate::menus::menu_items`] returns, in the order the +/// in-window bar draws them: at each depth leaves and separators in order, and a submenu where its +/// first child is. +pub fn from_items(tops: &[&str], items: &[MenuItem]) -> MenuBar { + fn level(rows: &[&MenuItem], depth: usize) -> Vec { + let mut out = Vec::new(); + let mut shown: Vec<&str> = Vec::new(); + for it in rows { + if it.path.len() == depth { + if it.label == "---" { + out.push(Node::Separator); + } else { + out.push(Node::Item(Item { + id: it.id.clone(), + label: it.label.clone(), + shortcut: it.shortcut.clone(), + enabled: it.enabled, + checked: it.checked, + role: None, + })); + } + } else if let Some(name) = it.path.get(depth) { + let name = name.as_str(); + if shown.contains(&name) { + continue; + } + shown.push(name); + let child: Vec<&MenuItem> = rows.iter().copied().filter(|c| c.path.get(depth).map(String::as_str) == Some(name)).collect(); + out.push(Node::Submenu { label: name.to_string(), children: level(&child, depth + 1), role: None }); + } + } + tidy(out) + } + let menus = tops + .iter() + .map(|top| { + let rows: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(*top)).collect(); + Menu { title: top.to_string(), role: MenuRole::Normal, children: level(&rows, 1) } + }) + .collect(); + MenuBar { menus } +} + +/// Drop separators at the start or end of a level and collapse runs of them, at every depth. +fn tidy(nodes: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(nodes.len()); + for n in nodes { + let n = match n { + Node::Submenu { label, children, role } => Node::Submenu { label, children: tidy(children), role }, + other => other, + }; + if matches!(n, Node::Separator) && matches!(out.last(), None | Some(Node::Separator)) { + continue; + } + out.push(n); + } + while matches!(out.last(), Some(Node::Separator)) { + out.pop(); + } + out +} + +/// A hash of everything a native menu can only change by rebuilding: menus, roles, item ids, +/// kinds (plain or check), shortcuts and submenu labels. Labels, enabled and checked are updated +/// in place. +pub fn structure_key(bar: &MenuBar) -> u64 { + fn walk(nodes: &[Node], h: &mut DefaultHasher) { + for n in nodes { + match n { + Node::Item(it) => ('i', &it.id, it.checked.is_some(), &it.shortcut, it.role).hash(h), + Node::Separator => '-'.hash(h), + Node::Submenu { label, children, role } => { + ('[', label, role).hash(h); + walk(children, h); + ']'.hash(h); + } + Node::Standard(s) => ('s', s).hash(h), + } + } + } + let mut h = DefaultHasher::new(); + for m in &bar.menus { + (&m.title, m.role).hash(&mut h); + walk(&m.children, &mut h); + } + h.finish() +} + +// ----------------------------------------------------------------------------- shortcuts + +/// A shortcut as PhotoCraft writes it (`Cmd+Shift+Z`). `Cmd` is ⌘ and `Ctrl` the Control key, +/// never folded together: ⌃⌘M and ⌘M are different shortcuts. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Chord { + pub cmd: bool, + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + /// The key as written: `Z`, `1`, `'`, `F5`, `Delete`, `Enter`. + pub key: String, +} + +impl Chord { + pub fn parse(s: &str) -> Option { + let mut c = Chord { cmd: false, ctrl: false, alt: false, shift: false, key: String::new() }; + for part in s.split('+') { + match part { + "Cmd" => c.cmd = true, + "Ctrl" => c.ctrl = true, + "Alt" => c.alt = true, + "Shift" => c.shift = true, + // `Cmd++` splits into an empty last part. + "" => c.key = "+".into(), + k if c.key.is_empty() => c.key = k.into(), + _ => return None, + } + } + (!c.key.is_empty()).then_some(c) + } + + /// The written form, modifiers in a fixed order. + pub fn portable(&self) -> String { + let mut parts = Vec::new(); + for (on, name) in [(self.ctrl, "Ctrl"), (self.cmd, "Cmd"), (self.alt, "Alt"), (self.shift, "Shift")] { + if on { + parts.push(name); + } + } + parts.push(&self.key); + parts.join("+") + } + + pub fn is_function_key(&self) -> bool { + self.key.strip_prefix('F').and_then(|n| n.parse::().ok()).is_some_and(|n| (1..=24).contains(&n)) + } + + /// May the native menu show this as a key equivalent? Only with ⌘ or ⌃, or a function key. + /// Bare letters and ⌥/⇧-only keys are tool keys: as key equivalents AppKit would take a `b` + /// typed into a text field. + pub fn native_ok(&self) -> bool { + self.cmd || self.ctrl || self.is_function_key() + } + + /// The key press egui would have received for this chord. + pub fn key_event(&self, pressed: bool) -> Option { + let key = match self.key.as_str() { + "+" => Key::Plus, + "'" => Key::Quote, + ";" => Key::Semicolon, + "=" => Key::Equals, + "-" => Key::Minus, + "[" => Key::OpenBracket, + "]" => Key::CloseBracket, + "\\" => Key::Backslash, + "/" => Key::Slash, + "," => Key::Comma, + "." => Key::Period, + k => Key::from_name(k)?, + }; + let modifiers = Modifiers { alt: self.alt, ctrl: self.ctrl, shift: self.shift, mac_cmd: self.cmd, command: self.cmd }; + Some(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }) + } +} + +// ----------------------------------------------------------------------------- macOS layout + +/// A system key equivalent PhotoCraft already uses for something else. +#[derive(Clone, Debug, PartialEq)] +pub struct Clash { + pub shortcut: &'static str, + /// What macOS uses it for. + pub system: &'static str, + /// The PhotoCraft command bound to it. + pub command: String, + /// What the layout did about it. + pub resolution: &'static str, +} + +pub struct Layout { + pub bar: MenuBar, + pub clashes: Vec, +} + +/// Turn PhotoCraft's menus (tagged with roles) into a Mac menu bar. See the module docs. +pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout { + let mut bar = bar.clone(); + let mut clashes = Vec::new(); + let bound: HashMap = bar.items().iter().filter_map(|it| Some((Chord::parse(it.shortcut.as_deref()?)?.portable(), it.id.clone()))).collect(); + let taken = |sc: &str, own: Option<&str>| bound.get(sc).filter(|cmd| Some(cmd.as_str()) != own).cloned(); + let named = |s: &str| crate::i18n::fmt(tr(lang, s), &[("app", APP_NAME)]); + + let about = take(&mut bar, |n| matches!(n, Node::Item(it) if it.role == Some(ItemRole::About))); + let settings = take(&mut bar, |n| match n { + Node::Item(it) => it.role == Some(ItemRole::Settings), + Node::Submenu { role, .. } => *role == Some(ItemRole::Settings), + _ => false, + }); + let quit = take(&mut bar, |n| matches!(n, Node::Item(it) if it.role == Some(ItemRole::Quit))); + let appearance = appearance_node(&mut bar, lang); + + let mut menu = Vec::new(); + if let Some(Node::Item(mut it)) = about { + it.label = named("About {app}"); + menu.extend([Node::Item(it), Node::Separator]); + } + if let Some(s) = settings { + let own = node_ids(&s); + let comma = bound.get("Cmd+,").filter(|cmd| !own.contains(*cmd)).cloned(); + menu.push(settings_node(s, comma, lang, &mut clashes)); + } + menu.extend(appearance); + if !menu.is_empty() && menu.last() != Some(&Node::Separator) { + menu.push(Node::Separator); + } + menu.extend([Node::Standard(Standard::Services), Node::Separator]); + let mut hide = Item::new(HIDE, &named("Hide {app}")); + hide.role = Some(ItemRole::Hide); + match taken("Cmd+H", None) { + Some(command) => { + let free = taken("Ctrl+Cmd+H", None).is_none(); + if free { + hide.shortcut = Some("Ctrl+Cmd+H".into()); + } + let resolution = if free { "Hide moves to ⌃⌘H, as in Photoshop" } else { "Hide has no shortcut" }; + clashes.push(Clash { shortcut: "Cmd+H", system: "Hide", command, resolution }); + } + None => hide.shortcut = Some("Cmd+H".into()), + } + menu.push(Node::Item(hide)); + if let Some(command) = taken("Cmd+Alt+H", None) { + clashes.push(Clash { shortcut: "Cmd+Alt+H", system: "Hide Others", command, resolution: "AppKit's Hide Others keeps ⌥⌘H" }); + } + menu.extend([Node::Standard(Standard::HideOthers), Node::Standard(Standard::ShowAll), Node::Separator]); + if let Some(Node::Item(mut quit)) = quit { + quit.label = named("Quit {app}"); + match taken("Cmd+Q", Some(&quit.id)) { + Some(command) => { + clashes.push(Clash { shortcut: "Cmd+Q", system: "Quit", command, resolution: "Quit has no shortcut" }); + quit.shortcut = None; + } + None => quit.shortcut = Some("Cmd+Q".into()), + } + menu.push(Node::Item(quit)); + } + bar.menus.insert(0, Menu { title: APP_NAME.into(), role: MenuRole::App, children: menu }); + + match taken("Cmd+W", None) { + Some(command) => clashes.push(Clash { shortcut: "Cmd+W", system: "Close Window", command, resolution: "no system Close Window item" }), + None => { + if let Some(file) = bar.menus.iter_mut().find(|m| m.title == "File") { + file.children.extend([Node::Separator, Node::Standard(Standard::CloseWindow)]); + } + } + } + + if bar.menu(MenuRole::Window).is_none() { + let at = bar.menus.iter().position(|m| m.role == MenuRole::Help).unwrap_or(bar.menus.len()); + bar.menus.insert(at, Menu { title: "Window".into(), role: MenuRole::Window, children: Vec::new() }); + } + let minimize = match taken("Cmd+M", None) { + Some(command) => { + let mut it = Item::new(MINIMIZE, tr(lang, "Minimize")); + it.role = Some(ItemRole::Minimize); + let free = taken("Ctrl+Cmd+M", None).is_none(); + if free { + it.shortcut = Some("Ctrl+Cmd+M".into()); + } + let resolution = if free { "Minimize moves to ⌃⌘M, as in Photoshop" } else { "Minimize has no shortcut" }; + clashes.push(Clash { shortcut: "Cmd+M", system: "Minimize", command, resolution }); + Node::Item(it) + } + None => Node::Standard(Standard::Minimize), + }; + if let Some(window) = bar.menus.iter_mut().find(|m| m.role == MenuRole::Window) { + let mut children = vec![minimize, Node::Standard(Standard::Zoom), Node::Separator]; + children.append(&mut window.children); + children.extend([Node::Separator, Node::Standard(Standard::BringAllToFront)]); + window.children = children; + } + + for m in &mut bar.menus { + m.children = tidy(std::mem::take(&mut m.children)); + } + // A menu emptied by the moves goes away (the Help menu stays: it has the search field). + bar.menus.retain(|m| !m.children.is_empty() || m.role == MenuRole::Help); + Layout { bar, clashes } +} + +/// Settings in the app menu: Edit › Preferences, a submenu of settings pages, becomes "Settings" +/// with ⌘, on its first page unless PhotoCraft binds ⌘, elsewhere. +fn settings_node(node: Node, comma_taken: Option, lang: Lang, clashes: &mut Vec) -> Node { + if let Some(command) = &comma_taken { + clashes.push(Clash { shortcut: "Cmd+,", system: "Settings", command: command.clone(), resolution: "Settings has no shortcut" }); + } + let give = |it: &mut Item| { + if comma_taken.is_none() && it.shortcut.is_none() { + it.shortcut = Some("Cmd+,".into()); + } + }; + match node { + Node::Item(mut it) => { + it.label = tr(lang, "Settings…").into(); + give(&mut it); + Node::Item(it) + } + Node::Submenu { mut children, role, .. } => { + if let Some(Node::Item(first)) = children.iter_mut().find(|n| matches!(n, Node::Item(i) if i.enabled)) { + give(first); + } + Node::Submenu { label: tr(lang, "Settings").into(), children, role } + } + other => other, + } +} + +/// Appearance in the app menu: the themes (Window › Theme, tagged [`ItemRole::Appearance`]), then +/// Next Theme, both leaving the Window menu. None when there are no themes. +fn appearance_node(bar: &mut MenuBar, lang: Lang) -> Option { + let Some(Node::Submenu { mut children, .. }) = take(bar, |n| matches!(n, Node::Submenu { role, .. } if *role == Some(ItemRole::Appearance))) else { + return None; + }; + if let Some(Node::Item(mut next)) = take(bar, |n| matches!(n, Node::Item(it) if it.role == Some(ItemRole::Appearance))) { + // Placed now: translated by id like the themes above it. + next.role = None; + children.extend([Node::Separator, Node::Item(next)]); + } + Some(Node::Submenu { label: tr(lang, "Appearance").into(), children, role: Some(ItemRole::Appearance) }) +} + +/// Command ids of the app menu's Language items: `app.language.`, where `auto` follows the +/// system (the `interface.language` preference values). +pub const LANGUAGE_PREFIX: &str = "app.language."; + +/// Language in the app menu, after Settings: Auto, then every UI language in its own name, with +/// the current preference checked. +fn language_node(pref: &str, lang: Lang) -> Node { + let item = |code: &str, label: &str| Item { + id: format!("{LANGUAGE_PREFIX}{code}"), + label: label.into(), + shortcut: None, + enabled: true, + checked: Some(pref == code), + role: Some(ItemRole::Language), + }; + let mut children = vec![Node::Item(item("auto", tr(lang, "Auto"))), Node::Separator]; + children.extend(Lang::all().map(|l| Node::Item(item(l.code(), l.name())))); + Node::Submenu { label: tr(lang, "Language").into(), children, role: Some(ItemRole::Language) } +} + +fn node_ids(node: &Node) -> Vec { + match node { + Node::Item(it) => vec![it.id.clone()], + Node::Submenu { children, .. } => children.iter().flat_map(node_ids).collect(), + _ => Vec::new(), + } +} + +/// Remove and return the first node matching `pred`, at any depth. +fn take(bar: &mut MenuBar, pred: impl Fn(&Node) -> bool + Copy) -> Option { + fn walk(nodes: &mut Vec, pred: impl Fn(&Node) -> bool + Copy) -> Option { + if let Some(i) = nodes.iter().position(pred) { + return Some(nodes.remove(i)); + } + nodes.iter_mut().find_map(|n| match n { + Node::Submenu { children, .. } => walk(children, pred), + _ => None, + }) + } + bar.menus.iter_mut().find_map(|m| walk(&mut m.children, pred)) +} + +/// Translate menu titles, submenus and items the layout didn't name itself. +fn translate(bar: &mut MenuBar, lang: Lang) { + fn walk(nodes: &mut [Node], lang: Lang) { + for n in nodes { + match n { + Node::Item(it) if it.role.is_none() => it.label = tr_id(lang, &it.id, &it.label).to_string(), + Node::Submenu { label, children, role } => { + if role.is_none() { + *label = tr(lang, label).to_string(); + } + walk(children, lang); + } + _ => {} + } + } + } + for m in &mut bar.menus { + if m.role != MenuRole::App { + m.title = tr(lang, &m.title).to_string(); + } + walk(&mut m.children, lang); + } +} + +/// PhotoCraft's menus as a Mac menu bar, in the UI language; `language` is the +/// `interface.language` preference (`auto` or a code), checked in the app menu's Language. +pub fn photocraft_layout(items: &[MenuItem], lang: Lang, language: &str) -> Layout { + let mut bar = from_items(&crate::menus::TOP_MENUS, items); + bar.tag_item("help.about", ItemRole::About); + bar.tag_item("file.exit", ItemRole::Quit); + bar.tag_submenu("Edit", "Preferences", ItemRole::Settings); + bar.tag_submenu("Window", "Theme", ItemRole::Appearance); + bar.tag_item("window.theme.toggle", ItemRole::Appearance); + bar.tag_menu("Window", MenuRole::Window); + bar.tag_menu("Help", MenuRole::Help); + let mut layout = mac_layout(&bar, lang); + // Language goes right after Settings (before Appearance), as in the other Craft apps. + if let Some(app_menu) = layout.bar.menus.iter_mut().find(|m| m.role == MenuRole::App) { + let settings = |n: &Node| match n { + Node::Item(it) => it.role == Some(ItemRole::Settings), + Node::Submenu { role, .. } => *role == Some(ItemRole::Settings), + _ => false, + }; + let at = app_menu.children.iter().position(settings).map_or(0, |i| i + 1); + app_menu.children.insert(at, language_node(language, lang)); + } + translate(&mut layout.bar, lang); + layout +} + +// ----------------------------------------------------------------------------- the app's side + +/// What the native menu reports. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + /// An item was chosen with the mouse (or the keyboard inside an open menu). + Click(String), + /// A key equivalent was pressed: the shortcut, to be handled as the key press it was. + Key(Chord), +} + +/// A platform's native menu bar (the desktop app implements it for macOS with muda). +pub trait Backend { + /// Bring the menu in step with `bar`: update labels, enabled and checked in place, or + /// rebuild when its structure changed. + fn sync(&mut self, bar: &MenuBar); + /// Events since the last call. Platform items (Hide, Services, Zoom, …) are handled by the + /// backend and not reported. + fn drain(&mut self) -> Vec; +} + +/// The native menu bar, while PhotoCraft uses one (the in-window menus are hidden then). +pub struct NativeMenu { + backend: Box, + /// Hash of the state the menus were last built from. + state: Option, + clicks: Vec, + /// AppKit toggled a check item itself: re-sync. + dirty: bool, + logged: bool, +} + +impl NativeMenu { + pub fn new(backend: Box) -> NativeMenu { + NativeMenu { backend, state: None, clicks: Vec::new(), dirty: true, logged: false } + } + + /// Before egui sees this frame's input: key equivalents become the key presses they were, + /// and clicks wait for [`NativeMenu::run`]. + pub fn raw_input(&mut self, raw: &mut egui::RawInput) { + for e in self.backend.drain() { + match e { + Event::Key(chord) => { + raw.events.extend(chord.key_event(true)); + raw.events.extend(chord.key_event(false)); + } + Event::Click(id) => self.clicks.push(id), + } + self.dirty = true; + } + } +} + +/// 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 }; + let clicks = std::mem::take(&mut menu.clicks); + for id in clicks { + if id == MINIMIZE { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + continue; + } + if let Err(e) = crate::menus::invoke(app, ctx, &id, serde_json::json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + } +} + +/// Bring the native menu up to date when anything it shows may have changed: a command ran, the +/// document or selection changed, or the user clicked or pressed a key. Building the rows walks +/// every command's enabled state, so it doesn't run on frames that only animate or scroll. +pub fn sync(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(menu) = app.services.native_menu.as_ref() else { return }; + let input = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. }))); + let state = state_hash(app, input.then_some(app.frame)); + if menu.state == Some(state) && !menu.dirty { + return; + } + let lang = crate::i18n::current(); + let layout = photocraft_layout(&crate::menus::menu_items(app), 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 { + log::info!("menu: {} is {} on macOS and {} in PhotoCraft; {}", c.shortcut, c.system, c.command, c.resolution); + } + menu.logged = true; + } + menu.backend.sync(&layout.bar); + menu.state = Some(state); + menu.dirty = false; +} + +/// What the menus' rows depend on, cheaply: commands run, the documents and their revisions, +/// selection, recent files, panels and view state, the language. `input` forces a change on +/// frames with a click or key press, which covers state the hash doesn't list. +fn state_hash(app: &PhotocraftApp, input: Option) -> u64 { + let mut h = DefaultHasher::new(); + input.hash(&mut h); + let s = &app.session; + s.journal.len().hash(&mut h); + s.active_index().hash(&mut h); + s.clipboard.is_some().hash(&mut h); + if let Some(d) = s.active() { + (d.doc.id.0, d.revision, d.active_layer.map(|l| l.0), d.selected_layers.len(), d.isolated_layers.len()).hash(&mut h); + } + 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); + 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() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn app(doc: bool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + if doc { + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.sync_views(); + } + app + } + + fn layout(doc: bool) -> Layout { + photocraft_layout(&crate::menus::menu_items(&app(doc)), Lang::EN, "auto") + } + + fn ids(nodes: &[Node]) -> Vec { + nodes + .iter() + .map(|n| match n { + Node::Item(it) => it.id.clone(), + Node::Separator => "---".into(), + Node::Submenu { label, .. } => format!("[{label}]"), + Node::Standard(s) => format!("<{s:?}>"), + }) + .collect() + } + + #[test] + fn photocraft_gets_a_mac_app_menu() { + let l = layout(true); + let app = &l.bar.menus[0]; + assert_eq!((app.title.as_str(), app.role), ("PhotoCraft", MenuRole::App)); + assert_eq!( + ids(&app.children), + [ + "help.about", + "---", + "[Settings]", + "[Language]", + "[Appearance]", + "---", + "", + "---", + HIDE, + "", + "", + "---", + "file.exit" + ] + ); + let quit = l.bar.find("file.exit").unwrap(); + assert_eq!((quit.label.as_str(), quit.shortcut.as_deref()), ("Quit PhotoCraft", Some("Cmd+Q"))); + assert_eq!(l.bar.find("help.about").unwrap().label, "About PhotoCraft"); + } + + #[test] + fn moved_items_leave_their_old_menus() { + let l = layout(true); + let by = |t: &str| l.bar.menus.iter().find(|m| m.title == t).unwrap(); + assert!(!ids(&by("File").children).contains(&"file.exit".to_string())); + assert!(!ids(&by("Help").children).contains(&"help.about".to_string())); + assert!(!ids(&by("Edit").children).contains(&"[Preferences]".to_string())); + let titles: Vec<&str> = l.bar.menus.iter().map(|m| m.title.as_str()).collect(); + assert_eq!(titles, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]); + } + + /// PhotoCraft keeps Photoshop's ⌘H, ⌘M, ⌘W and ⌘, (Hide Layers): the system items give way. + #[test] + fn clashes_are_resolved_in_photocrafts_favour() { + let l = layout(true); + let clash = |sc: &str| l.clashes.iter().find(|c| c.shortcut == sc).map(|c| c.command.as_str()); + assert_eq!(clash("Cmd+H"), Some("view.extras")); + assert_eq!(clash("Cmd+M"), Some("image.adjustments.curves")); + assert_eq!(clash("Cmd+W"), Some("file.close")); + assert_eq!(clash("Cmd+,"), Some("layer.hideLayers")); + assert_eq!(l.bar.find(HIDE).unwrap().shortcut.as_deref(), Some("Ctrl+Cmd+H"), "as in Photoshop"); + assert_eq!(l.bar.find(MINIMIZE).unwrap().shortcut.as_deref(), Some("Ctrl+Cmd+M")); + let window = l.bar.menu(MenuRole::Window).unwrap(); + assert_eq!(ids(&window.children)[..3], [MINIMIZE.to_string(), "".into(), "---".into()]); + assert_eq!(ids(&window.children).last().unwrap(), ""); + let file = l.bar.menus.iter().find(|m| m.title == "File").unwrap(); + assert!(!file.children.contains(&Node::Standard(Standard::CloseWindow)), "⌘W stays File › Close"); + } + + /// Themes and Next Theme move from Window to the app menu's Appearance, checked by the theme. + #[test] + fn themes_move_to_the_app_menus_appearance() { + let mut a = app(false); + a.ui.theme = crate::theme::ThemeKind::Classic; + let l = photocraft_layout(&crate::menus::menu_items(&a), Lang::EN, "auto"); + let app_menu = l.bar.menu(MenuRole::App).unwrap(); + let Some(Node::Submenu { children, .. }) = app_menu.children.iter().find(|n| matches!(n, Node::Submenu { label, .. } if label == "Appearance")) else { + panic!("an Appearance submenu"); + }; + let themes = ids(children); + assert_eq!(themes.last().map(String::as_str), Some("window.theme.toggle")); + assert!(themes.contains(&"window.theme.pro".to_string()) && themes.contains(&"window.theme.classic".to_string())); + assert_eq!(l.bar.find("window.theme.classic").unwrap().checked, Some(true)); + assert_eq!(l.bar.find("window.theme.pro").unwrap().checked, Some(false)); + assert_eq!(l.bar.find("window.theme.toggle").unwrap().label, "Next Theme"); + let window = ids(&l.bar.menu(MenuRole::Window).unwrap().children); + assert!(!window.iter().any(|id| id.starts_with("window.theme.") || id == "[Theme]"), "{window:?}"); + } + + /// Language follows Settings in the app menu: Auto and every UI language in its own name, + /// the preference checked; choosing one sets `interface.language`. + #[test] + fn the_app_menu_switches_the_ui_language() { + let l = photocraft_layout(&crate::menus::menu_items(&app(false)), Lang::EN, "ja"); + let app_menu = l.bar.menu(MenuRole::App).unwrap(); + let Some(Node::Submenu { children, .. }) = app_menu.children.iter().find(|n| matches!(n, Node::Submenu { label, .. } if label == "Language")) else { + panic!("a Language submenu"); + }; + assert_eq!(children.iter().filter(|n| matches!(n, Node::Item(_))).count(), Lang::all().count() + 1); + assert_eq!(l.bar.find("app.language.ja").map(|it| (it.label.as_str(), it.checked)), Some(("日本語", Some(true)))); + assert_eq!(l.bar.find("app.language.auto").and_then(|it| it.checked), Some(false)); + // Native names stay native in another UI language. + let de = photocraft_layout(&crate::menus::menu_items(&app(false)), Lang::from_pref("de"), "de"); + assert_eq!(de.bar.find("app.language.ja").map(|it| it.label.as_str()), Some("日本語")); + let mut a = app(false); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut a, &ctx, "app.language.fr", json!({})).unwrap(); + assert_eq!(a.session.prefs().interface.language, "fr"); + crate::menus::invoke(&mut a, &ctx, "app.language.auto", json!({})).unwrap(); + assert_eq!(a.session.prefs().interface.language, "auto"); + assert!(crate::menus::invoke(&mut a, &ctx, "app.language.xx-nope", json!({})).is_err(), "an unknown code is an error"); + } + + /// The menus show the shortcut that runs each command (Undo ⌘Z, Copy ⌘C were blank). + #[test] + fn menu_rows_show_the_shortcut_that_runs_them() { + let l = layout(true); + let sc = |id: &str| l.bar.find(id).and_then(|it| it.shortcut.clone()); + assert_eq!(sc("edit.undo").as_deref(), Some("Cmd+Z")); + assert_eq!(sc("edit.copy").as_deref(), Some("Cmd+C")); + assert_eq!(sc("edit.paste").as_deref(), Some("Cmd+V")); + assert_eq!(sc("layer.hideLayers").as_deref(), Some("Cmd+,")); + let app = app(true); + let bindings = crate::shortcut_dispatch::bindings(&app); + for it in crate::menus::menu_items(&app).iter().filter(|it| it.enabled) { + let Some(shown) = it.shortcut.as_deref().and_then(crate::shortcuts::parse) else { continue }; + let runs = bindings.iter().find(|(_, b)| *b == shown).map(|(id, _)| id.as_str()); + assert!(runs.is_some(), "{} shows {:?}, which runs nothing", it.id, it.shortcut); + } + } + + /// A native menu can't turn a plain item into a check item in place: opening the first + /// document must not change any item's kind, or the whole menu is rebuilt. + #[test] + fn opening_a_document_changes_state_not_structure() { + let (a, b) = (crate::menus::menu_items(&app(false)), crate::menus::menu_items(&app(true))); + let kind = |items: &[MenuItem]| items.iter().map(|i| (i.id.clone(), i.checked.is_some())).collect::>(); + let (ka, kb) = (kind(&a), kind(&b)); + let flips: Vec<&String> = ka.iter().filter(|(id, k)| kb.get(*id).is_some_and(|o| o != *k)).map(|(id, _)| id).collect(); + assert!(flips.is_empty(), "items that change kind: {flips:?}"); + assert_eq!(structure_key(&layout(false).bar), structure_key(&layout(true).bar)); + } + + #[test] + fn native_key_equivalents_are_command_and_function_keys_only() { + assert!(Chord::parse("Cmd+B").unwrap().native_ok()); + assert!(Chord::parse("F5").unwrap().native_ok()); + assert!(!Chord::parse("B").unwrap().native_ok()); + assert!(!Chord::parse("Alt+F").unwrap().native_ok()); + assert!(!Chord::parse("Shift+X").unwrap().native_ok()); + assert_eq!(Chord::parse("Ctrl+Cmd+M").unwrap().portable(), "Ctrl+Cmd+M"); + assert_eq!(Chord::parse("Cmd++").unwrap().key, "+"); + } + + /// Every menu shortcut is written so it parses and turns back into the key press egui gets. + #[test] + fn every_shortcut_becomes_a_key_press() { + for it in layout(true).bar.items() { + let Some(sc) = &it.shortcut else { continue }; + let c = Chord::parse(sc).unwrap_or_else(|| panic!("{sc} on {} doesn't parse", it.id)); + assert_eq!(&c.portable(), sc, "{} writes its modifiers in another order", it.id); + let Some(egui::Event::Key { key, modifiers, .. }) = c.key_event(true) else { panic!("{sc} on {} has no egui key", it.id) }; + let parsed = crate::shortcuts::parse(sc).unwrap(); + assert!(crate::shortcuts::key_matches(&parsed, key, modifiers), "{sc} on {} doesn't match its own key press", it.id); + } + } + + fn frame(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + } + + struct Fake { + synced: std::rc::Rc>>, + events: Vec, + } + + impl Backend for Fake { + fn sync(&mut self, bar: &MenuBar) { + self.synced.borrow_mut().push(bar.clone()); + } + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + } + + /// A key equivalent reaches PhotoCraft's own key handling as the key press it was, so the + /// shortcut runs once, through the same rules as without a native menu. + #[test] + fn key_equivalents_run_through_the_normal_shortcut_path() { + let synced = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let mut app = app(true); + let ctx = egui::Context::default(); + let rulers = Chord::parse("Cmd+R").unwrap(); + app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: synced.clone(), events: vec![Event::Key(rulers)] }))); + let before = app.ui.extras.rulers; + let mut raw = egui::RawInput::default(); + if let Some(m) = app.services.native_menu.as_mut() { + m.raw_input(&mut raw); + } + assert_eq!(raw.events.len(), 2, "a press and a release"); + frame(&ctx, raw, |ui| { + run(&mut app, ui.ctx()); + crate::shortcuts::handle(&mut app, ui.ctx()); + sync(&mut app, ui.ctx()); + }); + assert_eq!(app.ui.extras.rulers, !before, "⌘R toggled View › Rulers once"); + assert_eq!(synced.borrow().len(), 1, "the menu was built"); + // Nothing changed: no rebuild of the rows on the next frame. + frame(&ctx, egui::RawInput::default(), |ui| sync(&mut app, ui.ctx())); + assert_eq!(synced.borrow().len(), 1); + } + + #[test] + fn clicks_run_the_command_and_resync() { + let synced = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let mut app = app(true); + let ctx = egui::Context::default(); + app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: synced.clone(), events: vec![Event::Click("window.panel.layers".into())] }))); + let before = app.ui.panels.layers; + 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!(app.ui.panels.layers, !before); + let bar = synced.borrow().last().cloned().unwrap(); + assert_eq!(bar.find("window.panel.layers").unwrap().checked, Some(!before)); + } + + /// 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() { + use egui_kittest::{Harness, kittest::Queryable}; + let counts = |native: bool| -> Vec { + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = app(true); + if native { + app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: Default::default(), events: Vec::new() }))); + } + app + }); + h.run_steps(4); + crate::menus::TOP_MENUS.iter().map(|t| h.query_all_by_label(t).count()).collect() + }; + let (in_window, native) = (counts(false), counts(true)); + for (i, title) in crate::menus::TOP_MENUS.iter().enumerate() { + assert_eq!(native[i] + 1, in_window[i], "{title}: {} in-window, {} with the Mac menu bar", in_window[i], native[i]); + } + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 73967eaa..d11b103c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -333,7 +333,8 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (mark, _) = ui.allocate_exact_size(vec2(side, side), Sense::hover()); crate::brand::paint_mark(ui, mark); ui.add_space(6.0); - menus_right = crate::menus::menu_bar(app, ui); + // With the macOS menu bar the menus are at the top of the screen instead. + menus_right = if app.services.native_menu.is_some() { ui.cursor().left() } else { crate::menus::menu_bar(app, ui) }; // The menu bar takes the whole row, so the right-hand group gets its own rect: // from the menus to the bar's end, or to the caption buttons. let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() };