* 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:
parent
c9a7d26f6a
commit
b10b37c14b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() };
|
||||
|
|
|
|||
Loading…
Reference in New Issue