refactor(lightcraft-ui-egui): split menus.rs into menus/

This commit is contained in:
aiongg
2026-10-26 21:39:56 +00:00
parent 401e047acc
commit 15ece5cbd0
4 changed files with 2542 additions and 2529 deletions
File diff suppressed because it is too large Load Diff
+262
View File
@@ -0,0 +1,262 @@
use super::*;
/// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`),
/// which stay in the library and their folder; a smart album keeps them (its rules decide) and
/// says so. True when the key was handled here, false outside albums (then it deletes the photos
/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere.
pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false };
let Some(album) = app.session.catalog.album(aid) else { return false };
if album.is_smart() {
app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo"));
return true;
}
if app.session.selected().is_empty() {
return true;
}
match app.run("album.removePhotos", json!({"id": aid.0})) {
Ok(r) => {
let n = r["removed"].as_u64().unwrap_or(0);
let name = r["album"].as_str().unwrap_or_default().to_string();
let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n);
app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name));
}
Err(e) => app.toast(ctx, e),
}
true
}
/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead
/// of deleting; true when it did (the dialog's OK runs `photo.delete`).
pub fn confirm_delete(app: &mut LightcraftApp) -> bool {
if !app.ui.settings.confirm_delete {
return false;
}
let count = app.session.selected().len();
if count == 0 {
return false;
}
app.ui.dialog = Some(Dialog::ConfirmDelete { count });
true
}
/// Platform-appropriate label for revealing a file in the system file manager.
pub fn reveal_label() -> &'static str {
if cfg!(target_os = "macos") {
"Show in Finder"
} else if cfg!(target_os = "windows") {
"Show in Explorer"
} else {
"Show in File Manager"
}
}
/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be
/// attached to a report without hunting for the settings folder (#260).
pub(super) fn open_log_folder(app: &mut LightcraftApp) -> Result<Value, String> {
let path = app.services.log_file.clone().ok_or("this session keeps no log file")?;
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
reveal(&path)?;
Ok(json!({"path": path}))
}
/// Reveal the active photo's original in the system file manager.
pub(super) fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
let id = app.session.active().ok_or("no photo selected")?;
let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) {
Some(lightcraft_engine::catalog::Source::File { path }) => path,
_ => return Err("this photo has no file (demo scene)".into()),
};
let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
reveal(&path)?;
Ok(json!({"path": path}))
}
/// The folder the active photo was imported from, as the sidebar's Folders section counts it (a
/// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed
/// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show.
fn photo_folder(app: &LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Option<String> {
let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?;
let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None };
let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?;
(!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string())
}
/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its
/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to
/// look in, the folder alone decides, and the command still checks.
pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> bool {
let Some(dir) = photo_folder(app, id) else { return false };
app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some())
}
/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id`
/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its
/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the
/// section and the folders above it open, and it scrolls there (`reveal_folder`).
pub(super) fn show_in_folders(app: &mut LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Result<Value, String> {
let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?;
let tree = app.caches.folder_tree(&app.session.catalog);
// the row's own spelling (forward slashes): what its click hands `library.source`, and its id
let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?;
app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?;
app.ui.left_panel = true;
app.ui.reveal_folder = Some(path.clone());
Ok(json!({"path": path}))
}
/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original)
/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the
/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first,
/// so a missing editor leaves no copy behind. Once the copies exist, an editor that won't open them
/// is reported in the result's `failed` (and a toast), not as an error.
pub(super) fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result<Value, String> {
// every way in (menu, shortcut, context menu, the control channel) says why nothing opened
edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)))
}
fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result<Value, String> {
let settings = &app.ui.settings;
let (file, configured) = if photocraft {
let path = settings.photocraft_path.trim();
(settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() })
} else {
(settings.external_file, settings.external_editor.clone())
};
let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string);
if let Some(check) = app.services.check_editor.as_ref() {
check(&editor)?;
}
let mut params = file.params();
if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) {
for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") {
o.insert(k.clone(), v.clone());
}
}
let r = app.session.execute("photo.editExternal", &params).map_err(|e| e.to_string())?;
// every selected photo got a copy (#920); each opens in the editor
let copies: Vec<(String, Option<u64>)> = r["copies"]
.as_array()
.map(|c| c.iter().map(|c| (c["path"].as_str().unwrap_or_default().to_string(), c["id"].as_u64())).collect())
.unwrap_or_else(|| vec![(r["path"].as_str().unwrap_or_default().to_string(), r["id"].as_u64())]);
let mut failed = None;
for (path, id) in &copies {
if let Some(id) = id {
app.ui.external_edits.push(*id);
}
// the copy is LightCraft's own render, but it goes through the same gate as any other
// path handed to the system opener (#2135)
match lightcraft_engine::open_path::openable(path, "") {
Err(e) => failed = Some(e),
Ok(absolute) => {
if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) {
failed = Some(e);
}
}
}
}
let mut r = r;
if let Some(o) = r.as_object_mut() {
o.insert("failed".into(), json!(failed));
}
if let Some(e) = failed {
// the copies are in the library already (one undo step): an error would invite a retry
// that makes another set, so the result says what failed and the toast says why
app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e));
return Ok(r);
}
match copies.as_slice() {
[(path, _)] => {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name));
}
_ => app.toast(ctx, crate::i18n::tr_format!("{n} photos opened for editing; each is stacked with its original", n = copies.len())),
}
Ok(r)
}
/// Most originals one Open Original hands the editor (each starts it once).
const MAX_ORIGINALS: usize = 50;
/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file
/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor
/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor
/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to
/// open, it fails saying why.
pub(super) fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
let r = open_originals(app, p);
if let Err(e) = &r {
app.toast(ctx, e.clone());
}
let r = r?;
let opened = r["opened"].as_array().map_or(0, Vec::len);
match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) {
(Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)),
(None, Some(path)) if opened == 1 => {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name));
}
_ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)),
}
Ok(r)
}
fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
let mut ids = app.session.targets(p).map_err(|e| e.to_string())?;
ids.dedup();
if ids.is_empty() {
return Err("no photo selected".into());
}
if ids.len() > MAX_ORIGINALS {
return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len()));
}
let mut files = Vec::new();
let mut skipped = Vec::new();
for id in ids {
// the path may come from an imported catalog: only a photo file LightCraft reads is
// handed to the opener, never a program (#2135)
match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) {
Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) {
Ok(absolute) => files.push((id, absolute)),
Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})),
},
Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})),
None => skipped.push(json!({"id": id.0, "reason": "no such photo"})),
}
}
if files.is_empty() {
let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file");
return Err(if skipped.len() == 1 {
format!("nothing to open: {why}")
} else {
format!("nothing to open: none of these photos has its original file ({why})")
});
}
let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string);
if let Some(check) = app.services.check_editor.as_ref() {
check(&editor)?;
}
// all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the
// files that arrive while it is still starting
if let Some(open_all) = app.services.open_all_with.as_mut() {
let paths: Vec<String> = files.iter().map(|(_, path)| path.clone()).collect();
open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?;
let opened: Vec<Value> = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect();
return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null}));
}
let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?;
let mut opened = Vec::new();
let mut failed = None;
for (id, path) in files {
match open(&path, &editor) {
Ok(()) => opened.push(json!({"id": id.0, "path": path})),
Err(e) => failed = Some(e),
}
}
if opened.is_empty()
&& let Some(e) = failed
{
return Err(format!("Couldn't open the editor: {e}"));
}
Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed}))
}
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
use super::*;
/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language
/// has a matching menu command (also listed through the control channel). Tests check coverage.
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
// The picker order (`Locale::for_picker`): alphabetical by the language's own name after
// English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal.
("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"),
("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"),
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"),
("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"),
("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"),
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"),
("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"),
("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"),
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"),
("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared.
pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> {
LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS)
}
/// The language a Language-menu command selects, if the id is one. The engine and the UI both go
/// through here, so the menu, the settings row and the control channel agree on the mapping.
pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
match id {
"app.language.english" => Some(crate::i18n::Locale::En),
"app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans),
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
"app.language.portuguese" => Some(crate::i18n::Locale::PtBr),
"app.language.french" => Some(crate::i18n::Locale::Fr),
"app.language.spanish" => Some(crate::i18n::Locale::Es),
"app.language.german" => Some(crate::i18n::Locale::De),
"app.language.russian" => Some(crate::i18n::Locale::Ru),
"app.language.ukrainian" => Some(crate::i18n::Locale::Uk),
"app.language.polish" => Some(crate::i18n::Locale::Pl),
"app.language.czech" => Some(crate::i18n::Locale::Cs),
"app.language.italian" => Some(crate::i18n::Locale::It),
"app.language.georgian" => Some(crate::i18n::Locale::Ka),
"app.language.swedish" => Some(crate::i18n::Locale::Sv),
"app.language.finnish" => Some(crate::i18n::Locale::Fi),
"app.language.indonesian" => Some(crate::i18n::Locale::Id),
"app.language.vietnamese" => Some(crate::i18n::Locale::Vi),
_ => None,
}
}
pub const UI_COMMANDS: &[UiCommand] = &[
("ui.dock", "Arrange Panel", None, ""),
("ui.dock.reset", "Reset Panel Layout", None, "Window"),
("modelSetup.cancel", "Cancel Pending Photo Action", None, ""),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
("view.gridToggle", "Grid", Some("G"), ""),
("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"),
("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"),
("view.detail", "Detail View", Some("D"), "View"),
("view.compare", "Compare", Some("Shift+C"), "View"),
("view.survey", "Survey", Some("N"), "View"),
("view.people", "People", None, "View"),
("view.person", "Show Person", None, ""),
("view.faceBoxes", "Face Boxes", None, "View"),
("view.focusPoint", "Focus Point", Some("Alt+F"), "View"),
("view.zoomToFocus", "Zoom to Focus Point", None, "View"),
("view.reference", "Reference View", Some("Shift+R"), "View"),
("photo.setReference", "Set as Reference Photo", None, ""),
("compare.swap", "Swap Compare Photos", None, "View"),
("compare.set", "Show in Compare", None, ""),
("compare.makeSelect", "Make Candidate the Select", None, "View"),
("view.autoAdvance", "Auto Advance", None, "Photo"),
("view.filmstrip", "Filmstrip", Some("/"), "View"),
("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"),
("view.rightPanel", "Right Panel", None, "View"),
("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"),
("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"),
("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"),
("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"),
("view.showOriginal", "Show Original", Some("\\"), "View"),
("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"),
("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"),
("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"),
// the ratio a click (and Z / Space) zooms to
("view.clickZoom", "Click Zoom Ratio", None, ""),
("view.navigate", "Set Image Zoom and Pan", None, ""),
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
// Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key
("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"),
("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"),
("view.clipping", "Clipping", Some("J"), "View"),
("view.visualizeHdr", "Visualize HDR", None, "View"),
// in grids S expands/collapses stacks (the engine command it shadows)
("view.softProof", "Soft Proofing", Some("S"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
("view.maskOverlay", "Mask Overlay", Some("O"), "View"),
// Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one
("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"),
// Shift+O in the Masking panel (elsewhere it cycles the crop overlay)
("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"),
("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"),
("view.maskPins", "Mask Pins", None, "View"),
// {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next
// mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool
// Overlay and the Remove panel list the modes.
("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""),
("view.visualizeSpots", "Visualize Spots", Some("A"), "View"),
// A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps
// the key outside the panel; the two are a contextual pair in shortcuts::handle
("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""),
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"),
("view.back", "Back to Grid", Some("Escape"), ""),
("tool.done", "Done", Some("Enter"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("local.addRoot", "Add Folder to Local", None, ""),
("local.hide", "Remove from Local", None, ""),
("local.restoreHidden", "Show Hidden Local Locations", None, ""),
// {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos)
("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
("view.lightsOut", "Lights Out", Some("Shift+L"), "View"),
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"),
("view.viewOptions", "View Options…", Some("Cmd+J"), "View"),
("view.chooseLayoutImage", "Choose Layout Image…", None, "View"),
("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"),
("view.layoutImageOpacity", "Layout Image Opacity", None, ""),
("view.layoutImageMatte", "Layout Image Matte", None, ""),
("view.navigator", "Navigator", None, "View"),
("panel.edit", "Edit Panel", Some("E"), "Window"),
("panel.profiles", "Profile Browser", None, "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"),
// the Keywords panel with the cursor in its Add keyword box
("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""),
("panel.versions", "Versions", Some("Shift+V"), "Window"),
("panel.activity", "History", None, "Window"),
("panel.close", "Close Panel", None, ""),
// {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it
("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"),
("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"),
("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"),
("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"),
("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"),
("section.calibration", "Calibration", None, ""),
// {on?}; no params toggles. Opening one Edit section closes the others.
("view.soloSections", "Solo Mode", None, "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, ""),
// brush size / feather of the active brush (Masking brush, Remove tool and its selected spot)
("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"),
("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"),
("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"),
("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"),
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
("dialog.smartAlbum", "New Smart Album…", None, "File"),
("view.photoCounts", "Photo Counts", None, "View"),
("view.sidebarSection", "Show Sidebar Section", None, ""),
("view.sidebarMoveSection", "Move Sidebar Section", None, ""),
("view.sidebarReset", "Reset Sidebar Sections", None, ""),
("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"),
("view.slideshowOptions", "Slideshow Options…", None, "View"),
("dialog.slideshowExport", "Export Slideshow…", None, "View"),
("app.slideshowExport", "Export Slideshow", None, ""),
// the slideshow's playback controls (Space, ←/→ and Esc while it plays)
("slideshow.togglePause", "Pause / Resume Slideshow", None, ""),
("slideshow.next", "Next Slide", None, ""),
("slideshow.previous", "Previous Slide", None, ""),
("slideshow.end", "End Slideshow", None, ""),
("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"),
("tool.keywordPainter", "Keyword Painter", None, ""),
("view.gridInfo", "Grid Info", None, ""),
// {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the
// top bar's appearance button). View ▸ Appearance lists the modes.
("view.appearance", "Next Appearance Mode", None, ""),
// {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family.
("view.theme", "Theme", None, ""),
("dialog.allMetadata", "All Metadata…", None, "Photo"),
("dialog.faceModel", "Add Face Model…", None, ""),
("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"), "Edit"),
("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"),
("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"),
("dialog.export", "Export…", None, "File"),
("dialog.print", "Print…", Some("Cmd+P"), "File"),
("app.print", "Print to PDF", None, ""),
("dialog.contactSheet", "Contact Sheet PDF…", None, "File"),
("app.contactSheet", "Export Contact Sheet PDF", None, ""),
("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"),
("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"),
// {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened
// in the other editor, e.g. a raw developer
("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"),
("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"),
("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"),
("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"),
("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"),
("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"),
("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"),
("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"),
("file.addFolder", "Import from Folder…", None, "File"),
// {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or
// Settings) says (#2672, `lightroom_destination`)
("file.importLightroom", "Import Lightroom Catalog…", None, "File"),
("file.addFromDevice", "Import from Device", None, ""),
("file.findMissing", "Find Missing Photos…", None, "File"),
("file.backupLibrary", "Back Up Library…", None, "File"),
("file.restoreLibrary", "Restore Library from Backup…", None, "File"),
("photo.locate", "Locate Missing File…", None, ""),
("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""),
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
("file.importPresets", "Import Profiles & Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
// Masking panel ▸ Effect menu
("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""),
("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""),
("file.exportMaskEffects", "Export Effect Presets…", None, ""),
("file.importKeywords", "Import Keywords…", None, "File"),
("file.exportKeywords", "Export Keywords…", None, "File"),
// Edit panel ▸ Curve ▸ Point Curve dropdown
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
("app.newLibrary", "New Library…", None, "File"),
("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"),
("app.recentLibraries", "Recent Libraries", None, ""),
("app.clearRecentLibraries", "Clear Recent Libraries", None, ""),
// Settings ▸ Display
("app.displayProfile", "Display Profile", None, ""),
("app.about", "About LightCraft", None, "Help"),
("app.checkForUpdates", "Check for Updates…", None, "Help"),
("app.systemInfo", "System Info…", None, "Help"),
("app.openLogFolder", "Open Log Folder", None, "Help"),
// the frame-rate display (`frame_rate`): a debugging aid, never saved
("view.frameRate", "Frame Rate", None, "Help>Debug"),
("app.whatsNew", "What's New", None, "Help"),
("dialog.cull", "Assisted Culling…", None, "Photo"),
("app.help", "LightCraft Help", Some("F1"), "Help"),
("app.discord", "Join the ArtCraft Discord…", None, "Help"),
("app.feedback", "Send Feedback…", None, "Help"),
("app.website", "LightCraft Website", None, "Help"),
("app.github", "LightCraft on GitHub", None, "Help"),
("app.artcraft", "ArtCraft Website", None, "Help"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.setShortcut", "Set Keyboard Shortcut", None, ""),
("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""),
("app.export", "Export Now", None, ""),
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
("app.showInFolders", "Show in Folders", None, "Photo"),
("app.openSamModelFolder", "Show in File Manager", None, ""),
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
("dialog.labelNames", "Edit Color Label Names…", None, ""),
("dialog.captureTime", "Edit Capture Time…", None, "Photo"),
("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"),
("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"),
];