macOS: use the native menu bar instead of the in-window menus (#887) (#894)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 / <APP>_IN_WINDOW_MENUS opt-out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
Brennan Stehling 2026-10-08 06:22:28 -07:00 committed by GitHub
parent c9a7d26f6a
commit b10b37c14b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1614 additions and 8 deletions

40
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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<Chord>,
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<Menu>,
/// By muda id.
entries: HashMap<String, Entry>,
rx: Receiver<Chosen>,
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<Event> {
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<native_menu::NativeMenu> {
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<Accelerator> {
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::<usize>().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);
}
}

View File

@ -2,12 +2,15 @@
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
//! [--automation-write-root <dir>] [--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 <port>` (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

View File

@ -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<String> {
(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<NSMenu> {
(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<String> = (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<String> = 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() {}

View File

@ -82,8 +82,9 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
"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)
}

View File

@ -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<monitor_status::ReadDisplaysFn>,
/// The macOS menu bar, when the desktop app installed one; the in-window menus are hidden then.
pub native_menu: Option<native_menu::NativeMenu>,
}
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.

View File

@ -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<bool> {
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<bool> {
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<bool> {
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<MenuItem> {
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,

View File

@ -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<Menu>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Menu {
pub title: String,
pub role: MenuRole,
pub children: Vec<Node>,
}
/// 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<Node>,
role: Option<ItemRole>,
},
/// 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<String>,
pub enabled: bool,
/// `Some` for check items.
pub checked: Option<bool>,
pub role: Option<ItemRole>,
}
/// 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<Node> {
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<Node>) -> Vec<Node> {
let mut out: Vec<Node> = 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<Chord> {
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::<u8>().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<egui::Event> {
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<Clash>,
}
/// 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<String, String> = 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<String>, lang: Lang, clashes: &mut Vec<Clash>) -> 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<Node> {
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.<code>`, 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<String> {
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<Node> {
fn walk(nodes: &mut Vec<Node>, pred: impl Fn(&Node) -> bool + Copy) -> Option<Node> {
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<Event>;
}
/// The native menu bar, while PhotoCraft uses one (the in-window menus are hidden then).
pub struct NativeMenu {
backend: Box<dyn Backend>,
/// Hash of the state the menus were last built from.
state: Option<u64>,
clicks: Vec<String>,
/// AppKit toggled a check item itself: re-sync.
dirty: bool,
logged: bool,
}
impl NativeMenu {
pub fn new(backend: Box<dyn Backend>) -> 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>) -> 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<String> {
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]",
"---",
"<Services>",
"---",
HIDE,
"<HideOthers>",
"<ShowAll>",
"---",
"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(), "<Zoom>".into(), "---".into()]);
assert_eq!(ids(&window.children).last().unwrap(), "<BringAllToFront>");
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::<HashMap<_, _>>();
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<std::cell::RefCell<Vec<MenuBar>>>,
events: Vec<Event>,
}
impl Backend for Fake {
fn sync(&mut self, bar: &MenuBar) {
self.synced.borrow_mut().push(bar.clone());
}
fn drain(&mut self) -> Vec<Event> {
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<usize> {
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]);
}
}
}

View File

@ -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() };