diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index a4ef7cbee..18c896367 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen reveal: None, log_file: None, open_with: None, + open_all_with: None, check_editor: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index 9a8cc22d2..1a86adbc2 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> { c.spawn().map(|_| ()).map_err(|e| e.to_string()) } +/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection: +/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is +/// still starting when it is started once per file. The system default opens them one by one +/// (Explorer and `xdg-open` take one file). +pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> { + match all_command(launcher(app)?, paths) { + Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()), + None => paths.iter().try_for_each(|p| open_with(p, app)), + } +} + +/// The one command that opens all of `paths` in `launcher` (none for the system default). +fn all_command(launcher: Launcher, paths: &[String]) -> Option { + match launcher { + Launcher::Default => None, + Launcher::MacApp(app) => { + let mut c = Command::new("open"); + c.args(["-a", &app]).args(paths); + Some(c) + } + Launcher::Program(p) => { + let mut c = Command::new(p); + c.args(paths); + Some(c) + } + Launcher::Flatpak => { + let mut c = Command::new("flatpak"); + c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@"); + Some(c) + } + } +} + /// Windows: open `path` in its default application through Explorer, given the path as its one /// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own /// syntax (running what follows an `&` as a command). @@ -191,6 +224,25 @@ mod tests { assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]); } + /// Open Original with several photos: one start of the editor with every file, which a + /// single-window editor (DxO PhotoLab) needs to get them all. + #[test] + fn several_files_go_to_one_start_of_the_editor() { + let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()]; + let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::>(); + let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap(); + assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe"); + assert_eq!(args(&c), paths); + let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap(); + assert_eq!( + (c.get_program().to_string_lossy().into_owned(), args(&c)), + ("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()]) + ); + let c = all_command(Launcher::Flatpak, &paths).unwrap(); + assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]); + assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one"); + } + #[test] fn windows_lookup_prefers_the_recorded_install_folder() { let env = |k: &str| match k { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index cc71cc06d..c698403bf 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -450,6 +450,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), open_with: Some(Box::new(editors::open_with)), + open_all_with: Some(Box::new(editors::open_all_with)), check_editor: Some(Box::new(editors::check)), open_url: Some(Box::new(|url: &str| { if !url.starts_with("https://") { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..2543ab681 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "{name} opened in the other editor": "{name} im anderen Editor geöffnet", + "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..00e15051c 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "{name} opened in the other editor": "{name} abierto en el otro editor", + "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..02181d7cb 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", + "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..89f0e633b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "{name} opened in the other editor": "{name}を別のエディタで開きました", + "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..cae92dddb 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "{name} opened in the other editor": "{name} aberto no outro editor", + "{n} originals opened in the other editor": "{n} originais abertos no outro editor" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..81c568b58 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "{name} opened in the other editor": "{name} открыт в другом редакторе", + "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..3d7cc3ee2 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "{name} opened in the other editor": "{name} відкрито в іншому редакторі", + "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..9868f6697 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "{name} opened in the other editor": "已在其他编辑器中打开 {name}", + "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..131f0495e 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", + "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..59d8a0f0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -149,6 +149,8 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). +pub type OpenAllWithFn = Box Result<(), String>>; /// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: /// not known). pub type ScreenProfileFn = Box Option>>; @@ -214,6 +216,9 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// Open several files with one start of the editor (Open Original; desktop only): an editor + /// that keeps one window takes them all. None = [`Services::open_with`] for each. + pub open_all_with: Option, /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = /// assume it can). pub check_editor: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..b8a2e6928 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -166,6 +166,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("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"), @@ -1219,6 +1222,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option edit_in(app, &ctx, p, true), "photo.editInExternal" => edit_in(app, &ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), "dialog.cull" => { app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); Ok(Value::Null) @@ -1807,6 +1811,14 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "photo.editInPhotoCraft" | "photo.editInExternal" => app.session.active().is_some() && app.services.open_with.is_some(), "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), + "photo.openOriginalInExternal" => { + app.services.open_with.is_some() + && app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + } "app.showInFinder" => { app.services.reveal.is_some() && app @@ -1963,3 +1975,87 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: } 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. +fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { + 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 { + let mut ids = app.session.targets(p); + 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 { + match app.session.catalog.photo(id).map(|ph| &ph.source) { + Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), + Some(lightcraft_engine::catalog::Source::File { path }) => { + skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) + } + 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 = files.iter().map(|(_, path)| path.clone()).collect(); + open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; + let opened: Vec = 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})) +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..505538d87 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -948,6 +948,16 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Edit in Other Editor")).clicked() { let _ = app.run("photo.editInExternal", json!({})); } + // the raw itself, unrendered: for a raw developer set as the other editor + if ui + .add_enabled( + crate::menus::ui_enabled(app, "photo.openOriginalInExternal"), + egui::Button::new(crate::i18n::tr("Open Original in Other Editor (RAW, no copy)")), + ) + .clicked() + { + let _ = app.run("photo.openOriginalInExternal", json!({})); + } }); }); if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..68313fc9e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -165,6 +165,13 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); + hint( + ui, + t, + crate::i18n::tr( + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", + ), + ); } /// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..37930e312 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1024,6 +1024,89 @@ fn edit_in_photocraft_opens_a_psd_copy() { h.settle(SETTLE); } +/// Open Original in Other Editor: each selected photo's own file (a raw stays a raw) goes to the +/// configured editor, untouched and without a copy; demo photos and missing files say why. +#[test] +fn open_original_in_external_editor_hands_over_the_raw_itself() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-orig-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let o = opened.clone(); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let demo = session.catalog.photos().map(|p| p.id).next().unwrap(); + let mut file = |name: &str, exists: bool| { + let path = dir.join(name); + if exists { + std::fs::write(&path, b"raw sensor data").unwrap(); + } + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "NEF", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + (id, path.to_string_lossy().into_owned()) + }; + let (a, a_path) = file("DSC_0001.NEF", true); + let (b, b_path) = file("DSC_0002.NEF", true); + let (gone, _) = file("DSC_0003.NEF", false); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + let n = h.app.session.catalog.len(); + + exec(&mut h, "library.select", json!({"ids": [a.0, b.0, demo.0]})); + let r = exec(&mut h, "photo.openOriginalInExternal", json!({})); + let calls = opened.lock().unwrap().clone(); + assert_eq!(calls, vec![(a_path.clone(), "DxO PhotoLab".to_string()), (b_path.clone(), "DxO PhotoLab".to_string())]); + assert_eq!(r["skipped"][0]["id"], demo.0, "{r}"); + assert_eq!(h.app.session.catalog.len(), n, "no copy is added to the library"); + assert_eq!(std::fs::read(&a_path).unwrap(), b"raw sensor data", "the original is left as it is"); + assert!(std::fs::read_dir(&dir).unwrap().count() == 2, "nothing is written next to the originals"); + + // the editor can be named per call; the active photo must have a file for the menu + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [a.0], "app": "/opt/dxo"})); + assert_eq!(r["opened"][0]["path"], a_path.as_str()); + assert_eq!(opened.lock().unwrap().last().unwrap().1, "/opt/dxo"); + exec(&mut h, "library.select", json!({"ids": [a.0]})); + assert!(crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal")); + exec(&mut h, "library.select", json!({"ids": [demo.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal")); + + // a host that can start the editor once with every file does (DxO PhotoLab keeps one window + // and drops the files that arrive while it starts) + let batches: std::sync::Arc, String)>>> = Default::default(); + let bt = batches.clone(); + h.app.services.open_all_with = Some(Box::new(move |paths: &[String], app: &str| { + bt.lock().unwrap().push((paths.to_vec(), app.to_string())); + Ok(()) + })); + let singles = opened.lock().unwrap().len(); + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [a.0, b.0, demo.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(*batches.lock().unwrap(), vec![(vec![a_path.clone(), b_path.clone()], "DxO PhotoLab".to_string())]); + assert_eq!(opened.lock().unwrap().len(), singles, "one start for all, not one per file"); + + // a demo photo or a missing file: a clear error, nothing opened + let calls = opened.lock().unwrap().len(); + for (ids, why) in [(json!([demo.0]), "demo"), (json!([gone.0]), "missing")] { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": ids}}), T); + assert_eq!(r["ok"], false, "{r}"); + assert!(r.to_string().contains(why), "{r}"); + } + assert_eq!(opened.lock().unwrap().len(), calls); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..0df31434d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-EXP-PSD: Open Original in Other Editor; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -400,7 +400,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -506,7 +506,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | | MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder | | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | -| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor, and Edit In on a photo's right-click menu | +| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu | | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | | MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | | MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |