M16.1: native macOS menu bar + in-window menus from the command registry
menubar.rs builds File/Edit/View/Photo/Window/Help from engine + UI
commands (layout table for order and separators, parameterized submenus
for rating, flag, label, sort, add to album), with live labels ("Undo
Exposure"), enabled and checked state; ui.menu.tree exposes it. The
desktop app installs it natively on macOS with muda (pure Rust over
AppKit; no GTK — muda is a macOS-only dependency): app menu (About,
Services, Hide, Quit), Window menu (Minimize/Zoom/Bring All to Front),
Help menu; per-frame state sync; accelerators without Cmd and the text
editing ones step aside while a text field has focus; natively handled
shortcuts are skipped by the egui handler. Other hosts (web, Windows,
Linux) get the same menus in the top bar. Panels/sections/tools move to
the Window menu. Verified with System Events: menus listed with live
enabled state, View > Survey dispatches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e52736703
commit
673e1bf6cb
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<native_menu::NativeMenu>);
|
||||
|
||||
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,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
accel: Option<Accelerator>,
|
||||
label: String,
|
||||
enabled: bool,
|
||||
checked: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct NativeMenu {
|
||||
menu: Menu,
|
||||
items: HashMap<String, Item>,
|
||||
rx: Receiver<String>,
|
||||
/// 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<Accelerator> {
|
||||
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<MenuNode>)]) -> 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::<String>();
|
||||
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<MenuNode> = 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::<HashSet<_>>();
|
||||
}
|
||||
|
||||
/// 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<String, Item>) {
|
||||
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::<Vec<_>>(), &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<String>) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
|
||||
pub headless_host: bool,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
|
|
@ -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![],
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
enabled: bool,
|
||||
/// `Some` for toggles and radio-style choices.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
checked: Option<bool>,
|
||||
},
|
||||
Separator,
|
||||
Submenu {
|
||||
label: String,
|
||||
children: Vec<MenuNode>,
|
||||
},
|
||||
}
|
||||
|
||||
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<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> 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<bool> {
|
||||
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<Vec<MenuNode>> {
|
||||
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<MenuNode> = 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<MenuNode> = [
|
||||
("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<MenuNode> =
|
||||
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<MenuNode>) -> Vec<MenuNode> {
|
||||
let mut out: Vec<MenuNode> = 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<MenuNode>)> {
|
||||
let entries: Vec<MenuEntry> = 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<MenuNode> {
|
||||
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<String> = 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<Value, String> {
|
||||
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::<f32>()
|
||||
+ 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<f32> = 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<MenuNode> = 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<MenuNode> = 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), "⌫");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Re
|
|||
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.newFolder" => {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -51,16 +51,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
return;
|
||||
}
|
||||
let mut fire: Vec<String> = 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}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue