mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-EXP-PSD: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy)
Hands each selected photo's original file itself (a raw stays a raw: no
render, no copy, nothing added to the library) to the editor set in
Settings ▸ External Editing ▸ Other editor, e.g. DxO PhotoLab. Also on a
photo's right-click menu (Edit In).
- `photo.openOriginalInExternal {ids?, app?}` (UI command, menus.rs): up to
50 photos; demo photos and missing files are skipped with the reason; when
none has a file it fails saying why (toast + error, no panic). Enabled when
the active photo has a file and the host can start programs (not the web).
- Several photos go to one start of the editor (`Services::open_all_with`,
editors.rs `open_all_with`): a single-window editor such as DxO PhotoLab
drops the files that arrive while it is still starting when started once
per file. The system default ("" editor) still opens them one by one.
- Settings hint, translations of the two new toasts, parity rows
LR-EXP-PSD / MENU-FILE-PHOTOSHOP.
Tested by hand on Windows with DxO PhotoLab 8 (one and several raws).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9ae3b365a1
commit
17aafcd5bf
18 files changed
+286
-12
No files matched your search
@@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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")?;
|
||||
|
||||
@@ -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<Command> {
|
||||
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::<Vec<_>>();
|
||||
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 {
|
||||
|
||||
@@ -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://") {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}個の元ファイルを別のエディタで開きました"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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} 个原始文件"
|
||||
}
|
||||
@@ -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} 個原始檔案"
|
||||
}
|
||||
@@ -149,6 +149,8 @@ pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
/// Open a file in an application (`app` = "" for the system's default one).
|
||||
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
|
||||
/// Open several files with one start of an application (`app` as for [`OpenWithFn`]).
|
||||
pub type OpenAllWithFn = Box<dyn FnMut(&[String], &str) -> Result<(), String>>;
|
||||
/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`:
|
||||
/// not known).
|
||||
pub type ScreenProfileFn = Box<dyn FnMut() -> Option<Vec<u8>>>;
|
||||
@@ -214,6 +216,9 @@ pub struct Services {
|
||||
pub open_url: Option<OpenUrlFn>,
|
||||
/// Open a file in an external editor (Edit in External Editor; desktop only).
|
||||
pub open_with: Option<OpenWithFn>,
|
||||
/// 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<OpenAllWithFn>,
|
||||
/// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none =
|
||||
/// assume it can).
|
||||
pub check_editor: Option<CheckEditorFn>,
|
||||
|
||||
@@ -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<Re
|
||||
}
|
||||
"photo.editInPhotoCraft" => 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<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);
|
||||
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<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}))
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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-…`).
|
||||
|
||||
@@ -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<std::sync::Mutex<Vec<(String, String)>>> = 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<std::sync::Mutex<Vec<(Vec<String>, 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");
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user