diff --git a/Cargo.lock b/Cargo.lock index 01e13890..f22c48fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,6 +853,15 @@ dependencies = [ "cfg-if", ] +[[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" @@ -1927,6 +1936,16 @@ dependencies = [ "tracing", ] +[[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" @@ -2011,6 +2030,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-ui-egui", "log", + "muda", "rfd", "serde_json", ] @@ -2392,6 +2412,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" diff --git a/Cargo.toml b/Cargo.toml index 76abfa4c..9e2bb135 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ egui_extras = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" +muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 96d7e8ab..21de0243 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -21,3 +21,6 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +muda = { workspace = true } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index baac940f..7200568e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -13,14 +13,20 @@ //! See `lightcraft_ui_egui::control` for the methods. mod control_server; +#[cfg(target_os = "macos")] +mod native_menu; use lightcraft_engine::Session; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; -struct App(LightcraftApp); +struct App(LightcraftApp, #[cfg(target_os = "macos")] Option); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + if let Some(m) = self.1.as_mut() { + m.update(&mut self.0, ctx); + } self.0.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -221,7 +227,15 @@ fn main() -> eframe::Result { let _ = app.run("library.import", serde_json::json!({"paths": files})); app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; } - Ok(Box::new(App(app))) + // native menu bar generated from the command registry (macOS; elsewhere the menus are + // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. + #[cfg(target_os = "macos")] + let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + menu, + ))) }), ) } diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs new file mode 100644 index 00000000..cfb1bd4e --- /dev/null +++ b/apps/lightcraft/src/native_menu.rs @@ -0,0 +1,389 @@ +//! The native macOS menu bar (via `muda`, pure Rust over AppKit), generated from the same model as +//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts, +//! enabled and checked state, and live labels ("Undo Exposure"). +//! +//! Key handling: a native key equivalent consumes the key press before egui sees it, so +//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by +//! the egui shortcut handler (nothing fires twice); +//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the +//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come +//! back when the field loses focus. + +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::{Receiver, channel}; + +use lightcraft_ui_egui::LightcraftApp; +use lightcraft_ui_egui::menubar::{MenuNode, menu_bar}; +use muda::accelerator::{Accelerator, Code, Modifiers}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use serde_json::Value; + +const QUIT: &str = "app.quit"; +/// Shortcuts text fields need while they have focus. +const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"]; + +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +struct Item { + handle: Handle, + cmd: String, + params: Value, + shortcut: Option, + accel: Option, + label: String, + enabled: bool, + checked: Option, +} + +pub struct NativeMenu { + menu: Menu, + items: HashMap, + rx: Receiver, + /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. + structure: String, + text_focus: bool, +} + +/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). +fn accelerator(sc: &str) -> Option { + let mut mods = Modifiers::empty(); + let mut key = None; + for part in sc.split('+') { + match part { + "Cmd" => mods |= Modifiers::META, + "Shift" => mods |= Modifiers::SHIFT, + "Alt" => mods |= Modifiers::ALT, + "Ctrl" => mods |= Modifiers::CONTROL, + k => key = Some(k), + } + } + let code = match key? { + k if k.len() == 1 && k.as_bytes()[0].is_ascii_alphabetic() => { + 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, + ]; + LETTERS[(k.as_bytes()[0].to_ascii_uppercase() - b'A') as usize] + } + k if k.len() == 1 && k.as_bytes()[0].is_ascii_digit() => { + const DIGITS: [Code; 10] = [ + Code::Digit0, + Code::Digit1, + Code::Digit2, + Code::Digit3, + Code::Digit4, + Code::Digit5, + Code::Digit6, + Code::Digit7, + Code::Digit8, + Code::Digit9, + ]; + DIGITS[(k.as_bytes()[0] - b'0') as usize] + } + "[" => Code::BracketLeft, + "]" => Code::BracketRight, + "\\" => Code::Backslash, + "/" => Code::Slash, + "=" => Code::Equal, + "-" => Code::Minus, + "'" => Code::Quote, + "Delete" => Code::Backspace, + _ => return None, + }; + Some(Accelerator::new(mods, code)) +} + +/// Accelerators that must step aside while a text field has focus. +fn yields_to_text(sc: &str) -> bool { + !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) +} + +/// `&` marks a mnemonic in muda labels. +fn label_text(s: &str) -> String { + s.replace('&', "&&") +} + +fn structure_of(bar: &[(String, Vec)]) -> String { + fn walk(n: &[MenuNode], out: &mut String) { + for x in n { + match x { + MenuNode::Item { id, params, checked, shortcut, .. } => { + out.push_str(&MenuNode::key(id, params)); + out.push(if checked.is_some() { 'c' } else { 'i' }); + out.push_str(shortcut.as_deref().unwrap_or("")); + } + MenuNode::Separator => out.push('-'), + MenuNode::Submenu { label, children } => { + out.push('['); + out.push_str(label); + walk(children, out); + out.push(']'); + } + } + out.push(';'); + } + } + let mut s = String::new(); + for (t, items) in bar { + s.push_str(t); + walk(items, &mut s); + } + s +} + +impl NativeMenu { + /// Build the menu bar and install it as the application menu (main thread, after launch). + pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu { + let (tx, rx) = channel::(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + let _ = tx.send(e.id.0); + repaint.request_repaint(); + })); + let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false }; + m.rebuild(app); + app.native_menu = true; + m + } + + fn rebuild(&mut self, app: &mut LightcraftApp) { + let bar = menu_bar(app); + self.structure = structure_of(&bar); + self.menu = Menu::new(); + self.items.clear(); + + // the application menu + let app_menu = Submenu::new("LightCraft", true); + let about = MenuItem::with_id("app.about", "About LightCraft", true, None); + let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); + let _ = app_menu.append_items(&[ + &about, + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(Some("Hide LightCraft")), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &quit, + ]); + let _ = self.menu.append(&app_menu); + + for (title, nodes) in &bar { + let sub = Submenu::new(title, true); + if title == "Window" { + let _ = sub.append_items(&[ + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(Some("Zoom")), + &PredefinedMenuItem::separator(), + ]); + } + // About lives in the app menu on macOS + let nodes: Vec = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect(); + self.append_nodes(&sub, &nodes); + if title == "Window" { + let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); + sub.set_as_windows_menu_for_nsapp(); + } + if title == "Help" { + sub.set_as_help_menu_for_nsapp(); + } + let _ = self.menu.append(&sub); + } + self.menu.init_for_nsapp(); + self.text_focus = false; + self.publish_shortcuts(app); + } + + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode]) { + for n in nodes { + match n { + MenuNode::Separator => { + let _ = sub.append(&PredefinedMenuItem::separator()); + } + MenuNode::Submenu { label, children } => { + let s = Submenu::new(label_text(label), true); + self.append_nodes(&s, children); + let _ = sub.append(&s); + } + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { + let key = MenuNode::key(id, params); + if self.items.contains_key(&key) { + continue; // muda ids must be unique + } + let accel = shortcut.as_deref().and_then(accelerator); + let handle = match checked { + Some(c) => { + let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel); + let _ = sub.append(&it); + Handle::Check(it) + } + None => { + let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel); + let _ = sub.append(&it); + Handle::Plain(it) + } + }; + self.items.insert( + key, + Item { + handle, + cmd: id.clone(), + params: params.clone(), + shortcut: shortcut.clone(), + accel, + label: label.clone(), + enabled: *enabled, + checked: *checked, + }, + ); + } + } + } + } + + /// Tell the egui shortcut handler which shortcuts the menu bar currently owns. + fn publish_shortcuts(&self, app: &mut LightcraftApp) { + app.native_shortcuts = self + .items + .values() + .filter(|i| i.accel.is_some()) + .filter_map(|i| i.shortcut.clone()) + .filter(|sc| !(self.text_focus && yields_to_text(sc))) + .collect::>(); + } + + /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus + /// accelerators with the app state. + pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { + while let Ok(key) = self.rx.try_recv() { + if key == QUIT { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + continue; + } + if key == "app.about" { + let _ = app.run("app.about", serde_json::json!({})); + continue; + } + if let Some(it) = self.items.get(&key) { + let (cmd, params) = (it.cmd.clone(), it.params.clone()); + let _ = lightcraft_ui_egui::menubar::run_item(app, &cmd, params); + } + } + let bar = menu_bar(app); + if structure_of(&bar) != self.structure { + self.rebuild(app); + return; + } + fn walk(nodes: &[MenuNode], items: &mut HashMap) { + for n in nodes { + match n { + MenuNode::Submenu { children, .. } => walk(children, items), + MenuNode::Item { id, params, label, enabled, checked, .. } => { + let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; + if it.label != *label { + match &it.handle { + Handle::Plain(h) => h.set_text(label_text(label)), + Handle::Check(h) => h.set_text(label_text(label)), + } + it.label = label.clone(); + } + if it.enabled != *enabled { + match &it.handle { + Handle::Plain(h) => h.set_enabled(*enabled), + Handle::Check(h) => h.set_enabled(*enabled), + } + it.enabled = *enabled; + } + if let (Handle::Check(h), Some(c)) = (&it.handle, checked) { + // also resyncs after AppKit toggled it on click + if h.is_checked() != *c { + h.set_checked(*c); + } + it.checked = Some(*c); + } + } + MenuNode::Separator => {} + } + } + } + walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); + + let focus = ctx.egui_wants_keyboard_input(); + if focus != self.text_focus { + self.text_focus = focus; + for it in self.items.values() { + let Some(sc) = it.shortcut.as_deref() else { continue }; + if it.accel.is_none() || !yields_to_text(sc) { + continue; + } + let accel = if focus { None } else { it.accel }; + let _ = match &it.handle { + Handle::Plain(h) => h.set_accelerator(accel), + Handle::Check(h) => h.set_accelerator(accel), + }; + } + self.publish_shortcuts(app); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shortcuts_map_to_accelerators() { + assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ))); + assert_eq!(accelerator("G"), Some(Accelerator::new(Modifiers::empty(), Code::KeyG))); + assert_eq!(accelerator("Cmd+'"), Some(Accelerator::new(Modifiers::META, Code::Quote))); + assert_eq!(accelerator("Escape"), None); + assert!(yields_to_text("G") && yields_to_text("Delete") && yields_to_text("Cmd+V")); + assert!(!yields_to_text("Cmd+Shift+E")); + // every registry shortcut that a menu shows maps (or is deliberately left to egui) + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + fn all(n: &[MenuNode], out: &mut Vec) { + for x in n { + match x { + MenuNode::Item { shortcut: Some(s), .. } => out.push(s.clone()), + MenuNode::Submenu { children, .. } => all(children, out), + _ => {} + } + } + } + let mut scs = Vec::new(); + for (_, v) in menu_bar(&app) { + all(&v, &mut scs); + } + for sc in scs { + assert!(accelerator(&sc).is_some(), "{sc}"); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d6cbfae..27451daf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -132,6 +132,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } "engine.commands" => ok(all_commands(app)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), + "ui.menu.tree" => { + ok(Value::Array(crate::menubar::menu_bar(app).into_iter().map(|(title, items)| json!({"label": title, "children": items})).collect())) + } "ui.inspect" => ok(inspect(app, ctx)), "ui.widgets" => { let filter = s("filter").unwrap_or(""); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 9def0930..96aa5a3b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -335,6 +335,8 @@ mod tests { h.request("ui.set", json!({"view": "survey"}), t); h.request("ui.key", json!({"key": "escape"}), t); assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + // let in-flight renders finish: worker threads must not outlive the test process' TLS + h.settle(Duration::from_secs(20)); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0309967..3a525716 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod control; pub mod headless; pub mod icons; +pub mod menubar; pub mod menus; pub mod panels; pub mod render; @@ -59,8 +60,11 @@ pub struct LightcraftApp { pub perf: Perf, /// macOS: the host draws the traffic lights over our top bar. pub integrated_titlebar: bool, - /// The host installed a native menu bar. + /// The host installed a native menu bar (no in-window menus then). pub native_menu: bool, + /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler + /// leaves them alone so nothing fires twice. + pub native_shortcuts: std::collections::HashSet, /// The host is [`headless::Headless`] (it answers viewport screenshot commands itself). pub headless_host: bool, control_rx: Option>, @@ -92,6 +96,7 @@ impl LightcraftApp { perf: Perf::default(), integrated_titlebar: false, native_menu: false, + native_shortcuts: Default::default(), headless_host: false, control_rx: None, pending_screenshots: vec![], diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs new file mode 100644 index 00000000..0e74e2da --- /dev/null +++ b/crates/ui-egui/src/menubar.rs @@ -0,0 +1,576 @@ +//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry +//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts, +//! enabled and checked state. +//! +//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the +//! in-window menu bar (web, Windows, Linux — [`show_in_window`]) and the control channel +//! (`ui.menu.tree`). Items are a command id plus parameters, run through [`run_item`] so they +//! behave exactly like their keyboard shortcuts. + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::menus::MenuEntry; +use crate::state::{RightPanel, ViewMode}; + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MenuNode { + Item { + id: String, + #[serde(skip_serializing_if = "Value::is_null")] + params: Value, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + shortcut: Option, + enabled: bool, + /// `Some` for toggles and radio-style choices. + #[serde(skip_serializing_if = "Option::is_none")] + checked: Option, + }, + Separator, + Submenu { + label: String, + children: Vec, + }, +} + +impl MenuNode { + /// `id|params` — a stable key for one item (native menu ids). + pub fn key(id: &str, params: &Value) -> String { + if params.is_null() { id.to_string() } else { format!("{id}|{params}") } + } +} + +/// Top-level menus in order (the macOS app menu is added by the host). +pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; + +/// Order and grouping per menu: command ids, `---` separators and `@Submenu` placeholders. +/// Entries with a menu path that aren't listed are appended at the end of their menu. +const LAYOUT: &[(&str, &[&str])] = &[ + ( + "File", + &[ + "file.addPhotos", + "---", + "dialog.newAlbum", + "dialog.newFolder", + "dialog.newSmartAlbum", + "---", + "file.importPresets", + "file.exportPresets", + "---", + "dialog.export", + "---", + "library.toggleAutoWriteXmp", + "---", + ], + ), + ( + "Edit", + &["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"], + ), + ( + "View", + &[ + "view.photoGrid", + "view.squareGrid", + "view.detail", + "view.compare", + "view.survey", + "---", + "view.leftPanel", + "view.filmstrip", + "view.histogram", + "---", + "view.showOriginal", + "view.beforeAfter", + "view.beforeAfterSplit", + "---", + "view.zoomIn", + "view.zoomOut", + "view.zoomToggle", + "view.zoomFit", + "view.zoom100", + "---", + "view.clipping", + "view.maskOverlay", + "view.cropOverlay", + "---", + "@Sort", + "@Stacks", + "view.filterBar", + "library.clearFilter", + "---", + "compare.swap", + "compare.makeSelect", + ], + ), + ( + "Photo", + &[ + "@Add to Album", + "---", + "@Set Rating", + "@Set Flag", + "@Set Color Label", + "view.autoAdvance", + "---", + "photo.rotateLeft", + "photo.rotateRight", + "photo.flipHorizontal", + "photo.flipVertical", + "---", + "version.create", + "photo.virtualCopy", + "@Stack", + "---", + "develop.auto", + "develop.treatment", + "develop.reset", + "dialog.createPreset", + "---", + "photo.saveMetadataToFile", + "photo.readMetadataFromFile", + "---", + "photo.delete", + ], + ), + ( + "Window", + &[ + "panel.edit", + "panel.crop", + "panel.remove", + "panel.masking", + "panel.redeye", + "---", + "panel.presets", + "panel.versions", + "panel.activity", + "panel.info", + "panel.keywords", + "---", + "@Edit Sections", + "@Tools", + ], + ), + ("Help", &["app.shortcuts", "---", "app.about"]), +]; + +/// Registry entries that are reached another way (parameterized commands are expanded into +/// submenus below; others duplicate a dialog command). +const HIDDEN: &[&str] = &[ + "photo.rate", + "photo.flag", + "photo.pick", + "photo.reject", + "photo.unflag", + "photo.label", + "library.sort", + "album.addPhotos", + "album.create", + "library.import", + "preset.create", +]; + +fn item(id: &str, params: Value, label: impl Into, shortcut: Option<&str>, enabled: bool, checked: Option) -> MenuNode { + MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } +} + +/// Checked state of toggles and radio items. +pub fn checked(app: &LightcraftApp, id: &str) -> Option { + let u = &app.ui; + let panel = |p: RightPanel| Some(u.right == p); + match id { + "view.photoGrid" => Some(u.view == ViewMode::PhotoGrid), + "view.squareGrid" => Some(u.view == ViewMode::SquareGrid), + "view.detail" => Some(u.view == ViewMode::Detail), + "view.compare" => Some(u.view == ViewMode::Compare), + "view.survey" => Some(u.view == ViewMode::Survey), + "view.leftPanel" => Some(u.left_panel), + "view.filmstrip" => Some(u.filmstrip), + "view.histogram" => Some(u.histogram), + "view.clipping" => Some(u.show_clipping), + "view.maskOverlay" => Some(u.mask_overlay), + "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), + "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), + "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), + "view.autoAdvance" => Some(u.auto_advance), + "panel.edit" => panel(RightPanel::Edit), + "panel.crop" => panel(RightPanel::Crop), + "panel.remove" => panel(RightPanel::Remove), + "panel.masking" => panel(RightPanel::Masking), + "panel.redeye" => panel(RightPanel::RedEye), + "panel.versions" => panel(RightPanel::Versions), + "panel.activity" => panel(RightPanel::Activity), + "panel.info" => panel(RightPanel::Info), + "panel.keywords" => panel(RightPanel::Keywords), + "panel.presets" => Some(u.presets), + "library.toggleAutoWriteXmp" => Some(app.session.xmp.auto_write), + _ => None, + } +} + +/// Labels that follow the state ("Undo Exposure", "Delete 3 Photos"). +fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { + let n = app.session.selection.ids.len(); + match id { + "edit.undo" => app.session.undo.last().map(|e| format!("Undo {}", e.label)).unwrap_or_else(|| "Undo".into()), + "edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()), + "photo.delete" if n > 1 => format!("Delete {n} Photos"), + "photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"), + _ => label.to_string(), + } +} + +/// The parameterized submenus. +fn expanded(app: &LightcraftApp, name: &str) -> Option> { + let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); + let has = active.is_some(); + Some(match name { + "Set Rating" => (0..=5u8) + .map(|r| { + let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) }; + let sc = ["0", "1", "2", "3", "4", "5"][r as usize]; + item("photo.rate", json!({"rating": r}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.rating == r))) + }) + .collect(), + "Set Flag" => [ + ("pick", "Pick", "P", lightcraft_catalog::Flag::Pick), + ("reject", "Reject", "X", lightcraft_catalog::Flag::Reject), + ("none", "Unflagged", "U", lightcraft_catalog::Flag::None), + ] + .into_iter() + .map(|(f, label, sc, flag)| { + item("photo.flag", json!({"flag": f}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.flag == flag))) + }) + .collect(), + "Set Color Label" => { + let mut v: Vec = lightcraft_catalog::ColorLabel::ALL + .iter() + .zip([Some("6"), Some("7"), Some("8"), Some("9"), None]) + .map(|(l, sc)| { + let name = format!("{l:?}"); + item( + "photo.label", + json!({"label": name.to_lowercase()}), + name, + sc, + has, + Some(active.as_ref().is_some_and(|p| p.label == Some(*l))), + ) + }) + .collect(); + v.push(MenuNode::Separator); + v.push(item("photo.label", json!({"label": "none"}), "None", None, has, Some(active.as_ref().is_some_and(|p| p.label.is_none())))); + v + } + "Sort" => { + use lightcraft_catalog::SortKey::*; + let cur = app.session.sort; + let mut v: Vec = [ + ("Capture Date", CaptureDate, "captureDate"), + ("Import Date", ImportDate, "importDate"), + ("Modified Date", EditDate, "editDate"), + ("File Name", FileName, "fileName"), + ("Rating", Rating, "rating"), + ("File Size", FileSize, "fileSize"), + ] + .into_iter() + .map(|(label, key, k)| item("library.sort", json!({"key": k}), label, None, true, Some(cur.key == key))) + .collect(); + v.push(MenuNode::Separator); + v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, true, Some(cur.ascending))); + v.push(item("library.sort", json!({"ascending": false}), "Descending", None, true, Some(!cur.ascending))); + v + } + "Add to Album" => { + let sel = !app.session.selection.ids.is_empty() || has; + let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); + albums.sort_by_key(|(n, _)| n.to_lowercase()); + let mut v: Vec = + albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel, None)).collect(); + if !v.is_empty() { + v.push(MenuNode::Separator); + } + v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, true, None)); + v + } + _ => return None, + }) +} + +fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { + item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id)) +} + +/// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus. +fn tidy(v: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(v.len()); + for n in v { + let n = match n { + MenuNode::Submenu { label, children } => { + let children = tidy(children); + if children.is_empty() { + continue; + } + MenuNode::Submenu { label, children } + } + n => n, + }; + if n == MenuNode::Separator && out.last().is_none_or(|l| *l == MenuNode::Separator) { + continue; + } + out.push(n); + } + while out.last() == Some(&MenuNode::Separator) { + out.pop(); + } + out +} + +/// The whole menu bar: (title, items) per menu in [`MENUS`] order. +pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { + let entries: Vec = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect(); + let mut used = vec![false; entries.len()]; + let mut bar = Vec::new(); + for title in MENUS { + let layout = LAYOUT.iter().find(|(t, _)| t == title).map(|(_, l)| *l).unwrap_or(&[]); + let mut items = Vec::new(); + let sub = |name: &str, used: &mut [bool]| -> Vec { + let mut children = expanded(app, name).unwrap_or_default(); + let mut dialogs = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if !used[i] && e.menu.len() == 2 && e.menu[0] == *title && e.menu[1] == name { + used[i] = true; + // commands that open a dialog go last, after a separator + if e.label.ends_with('…') { dialogs.push(node(app, e)) } else { children.push(node(app, e)) } + } + } + if !dialogs.is_empty() { + children.push(MenuNode::Separator); + children.extend(dialogs); + } + children + }; + for slot in layout { + if *slot == "---" { + items.push(MenuNode::Separator); + } else if let Some(name) = slot.strip_prefix('@') { + let children = sub(name, &mut used); + items.push(MenuNode::Submenu { label: name.to_string(), children }); + } else if let Some(i) = entries.iter().position(|e| e.id == *slot && e.menu.first().map(String::as_str) == Some(title)) { + used[i] = true; + items.push(node(app, &entries[i])); + } + } + // everything else that names this menu + items.push(MenuNode::Separator); + let mut extra_subs: Vec = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if used[i] || e.menu.first().map(String::as_str) != Some(title) { + continue; + } + if e.menu.len() == 1 { + used[i] = true; + items.push(node(app, e)); + } else if !extra_subs.contains(&e.menu[1]) { + extra_subs.push(e.menu[1].clone()); + } + } + for name in extra_subs { + let children = sub(&name, &mut used); + items.push(MenuNode::Submenu { label: name, children }); + } + bar.push((title.to_string(), tidy(items))); + } + bar +} + +/// Run a menu item. Rating, flag and label items behave like their keys (the active photo only in +/// Compare/Survey; Auto Advance moves on). +pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result { + let mut params = if params.is_null() { json!({}) } else { params }; + let culling_cmd = matches!(id, "photo.rate" | "photo.flag" | "photo.label" | "photo.pick" | "photo.reject" | "photo.unflag"); + if culling_cmd { + crate::panels::compare::target_active(app, &mut params); + } + let r = app.run(id, params); + if culling_cmd && r.is_ok() && app.ui.auto_advance { + crate::panels::compare::advance(app); + } + r +} + +/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere. +pub fn shortcut_text(sc: &str, mac: bool) -> String { + if mac { + let mut mods = String::new(); + let mut key = ""; + for part in sc.split('+') { + match part { + "Ctrl" => mods.push('⌃'), + "Alt" => mods.push('⌥'), + "Shift" => mods.push('⇧'), + "Cmd" => mods.push('⌘'), + k => key = k, + } + } + let key = match key { + "Delete" => "⌫", + "Escape" => "⎋", + "Left" => "←", + "Right" => "→", + k => k, + }; + format!("{mods}{key}") + } else { + sc.replace("Cmd", "Ctrl") + } +} + +/// Width of the in-window menu bar's titles. +pub fn bar_width(ui: &egui::Ui) -> f32 { + let t = crate::theme::Tokens::get(ui.ctx()); + MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + 16.0).sum::() + + ui.spacing().item_spacing.x * MENUS.len() as f32 +} + +/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single +/// "Menu" button when the space is too narrow. Returns the width used. +pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32) -> f32 { + let t = crate::theme::Tokens::get(ui.ctx()); + let bar = menu_bar(app); + let font = t.font(13.0); + let widths: Vec = bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + 16.0).collect(); + let total: f32 = widths.iter().sum(); + let mut clicked: Option<(String, Value)> = None; + let start = ui.cursor().left(); + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + if total <= max_width { + for (title, items) in &bar { + let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); + egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked)); + } + } else { + let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), "menu:all", r.rect); + egui::Popup::menu(&r).show(|ui| { + for (title, items) in &bar { + ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked)); + } + }); + } + if let Some((id, params)) = clicked { + let _ = run_item(app, &id, params); + } + ui.cursor().left() - start +} + +fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) { + ui.set_min_width(220.0); + for n in nodes { + match n { + MenuNode::Separator => { + ui.separator(); + } + MenuNode::Submenu { label, children } => { + ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked)); + } + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { + // a gutter for check marks, like native menus + let mut b = egui::Button::new(format!(" {label}")); + if let Some(sc) = shortcut { + b = b.shortcut_text(shortcut_text(sc, mac)); + } + let r = ui.add_enabled(*enabled, b); + if *checked == Some(true) { + let t = crate::theme::Tokens::get(ui.ctx()); + let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0)); + crate::icons::paint(ui.painter(), c, crate::icons::Icon::Check, if *enabled { t.text } else { t.text_disabled }); + } + if r.clicked() { + *clicked = Some((id.clone(), params.clone())); + ui.close(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) + } + + fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, .. } if i == id => Some(n), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) + } + + #[test] + fn bar_follows_the_registry_and_state() { + let mut app = app(); + let bar = menu_bar(&app); + let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(titles, MENUS); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + // every engine command with a menu path is reachable (parameterized ones via submenus) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) { + assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); + } + for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { + assert!(find(&all, id).is_some(), "{id}"); + } + // no doubled or dangling separators + fn check(v: &[MenuNode]) { + assert_ne!(v.first(), Some(&MenuNode::Separator)); + assert_ne!(v.last(), Some(&MenuNode::Separator)); + for w in v.windows(2) { + assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); + } + for n in v { + if let MenuNode::Submenu { children, .. } = n { + check(children); + } + } + } + bar.iter().for_each(|(_, v)| check(v)); + // live state: undo label, enabled, checked + let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert!(!enabled); + run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); + let rated: Vec<_> = all + .iter() + .filter_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), + _ => None, + }) + .flatten() + .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) + .collect(); + assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); + let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; + assert_eq!(*checked, Some(app.ui.filmstrip)); + } + + #[test] + fn shortcut_text_per_platform() { + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_text("Delete", true), "⌫"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 35785e70..a43c9885 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -33,39 +33,40 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), - ("view.back", "Back to Grid", Some("Escape"), "View"), + ("view.back", "Back to Grid", Some("Escape"), ""), ("view.filterBar", "Filter", None, "View"), - ("panel.edit", "Edit", Some("E"), "View"), - ("panel.crop", "Crop & Rotate", Some("C"), "View"), - ("panel.remove", "Remove", Some("H"), "View"), - ("panel.masking", "Masking", Some("M"), "View"), - ("panel.redeye", "Red Eye", None, "View"), - ("panel.presets", "Presets", Some("Shift+P"), "View"), - ("panel.info", "Info", Some("I"), "View"), - ("panel.keywords", "Keywords", Some("K"), "View"), - ("panel.versions", "Versions", Some("Shift+V"), "View"), - ("panel.activity", "History", Some("Shift+Y"), "View"), - ("panel.close", "Close Panel", None, "View"), - ("section.light", "Light", Some("Cmd+Alt+1"), "View"), - ("section.color", "Color", Some("Cmd+Alt+2"), "View"), - ("section.effects", "Effects", Some("Cmd+Alt+3"), "View"), - ("section.detail", "Detail", Some("Cmd+Alt+4"), "View"), - ("section.optics", "Optics", Some("Cmd+Alt+5"), "View"), - ("tool.brush", "Brush", Some("B"), "View"), - ("tool.linear", "Linear Gradient", Some("L"), "View"), - ("tool.radial", "Radial Gradient", Some("R"), "View"), - ("tool.wbPicker", "White Balance Selector", Some("W"), "View"), - ("tool.none", "No Tool", None, "View"), + ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.crop", "Crop & Rotate", Some("C"), "Window"), + ("panel.remove", "Remove", Some("H"), "Window"), + ("panel.masking", "Masking", Some("M"), "Window"), + ("panel.redeye", "Red Eye", None, "Window"), + ("panel.presets", "Presets", Some("Shift+P"), "Window"), + ("panel.info", "Info", Some("I"), "Window"), + ("panel.keywords", "Keywords", Some("K"), "Window"), + ("panel.versions", "Versions", Some("Shift+V"), "Window"), + ("panel.activity", "History", None, "Window"), + ("panel.close", "Close Panel", None, ""), + ("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"), + ("tool.brush", "Brush", Some("B"), "Window>Tools"), + ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), + ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), + ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), + ("tool.none", "No Tool", None, ""), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), + ("dialog.newFolder", "New Folder…", None, "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), - ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), + ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), ("file.importPresets", "Import Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), - ("app.about", "About LightCraft", None, "LightCraft"), + ("app.about", "About LightCraft", None, "Help"), ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), ("app.export", "Export Now", None, ""), ]; @@ -295,6 +296,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true }); + Ok(Value::Null) + } "dialog.createPreset" => { app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7429a05d..979f9128 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -16,6 +16,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + if !app.native_menu { + // leave room for the in-window menus left of the (centred) search field + let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; + sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + } + let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() { @@ -26,9 +33,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("view.back", json!({})); } icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward"); + if !app.native_menu { + // no native menu bar (web, Windows, Linux): menus in the top bar + ui.add_space(10.0); + let room = search_left - ui.cursor().left() - 16.0; + crate::menubar::show_in_window(app, ui, room); + } }); // search field (centred on the window) - let sw = 640.0f32.min(full.width() - 460.0).max(200.0); let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); let id = egui::Id::new("search-field"); let focused = ui.memory(|m| m.has_focus(id)); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a3487c3b..cce9defd 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -51,16 +51,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { return; } let mut fire: Vec = Vec::new(); + // shortcuts the native menu bar handles (it consumes those key presses itself) + let native = |sc: &str| app.native_shortcuts.contains(sc); ctx.input(|i| { for (id, _, sc, _) in crate::menus::UI_COMMANDS { - if let Some((m, k)) = sc.and_then(parse) + if let Some((m, k)) = sc.filter(|s| !native(s)).and_then(parse) && matches(i, m, k) { fire.push(id.to_string()); } } for c in lightcraft_engine::command_specs() { - if let Some((m, k)) = c.shortcut.and_then(parse) + if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse) && matches(i, m, k) { fire.push(c.id.to_string()); @@ -68,15 +70,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } // rating 0-5, colour labels 6-9 (with Shift: and advance) for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) { fire.push(format!("rate:{n}:0")); } if matches(i, Modifiers::SHIFT, *key) { fire.push(format!("rate:{n}:1")); } } - for (label, key) in [("red", Key::Num6), ("yellow", Key::Num7), ("green", Key::Num8), ("blue", Key::Num9)] { - if matches(i, Modifiers::NONE, key) { + for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { + if matches(i, Modifiers::NONE, key) && !native(sc) { fire.push(format!("label:{label}")); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c3cb8103..71d5ee05 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,7 +19,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: |---|---|---| | `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) | | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | -| `ui.menu.list` | — | Menu entries | +| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | +| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | | `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget |