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:
Brandon Thomas 2026-10-01 10:18:02 -04:00
parent 1e52736703
commit 673e1bf6cb
13 changed files with 1086 additions and 34 deletions

39
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -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,
)))
}),
)
}

View File

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

View File

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

View File

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

View File

@ -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![],

View File

@ -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), "⌫");
}
}

View File

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

View File

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

View File

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

View File

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