LR-EXP-PSD + KEY-PHOTOSHOP: Edit in External Editor (⇧⌘E)

photo.editExternal renders the active photo with its edits as a 16-bit TIFF "<name>-Edit.tif"
(Adobe RGB by default; ProPhoto / P3 / sRGB) next to the original, never overwriting, adds it
stacked on top of the original and selects it. Photo ▸ Edit in External Editor (⇧⌘E, as
observed; the export dialog stays on ⇧E) then opens it in the application set in Settings ▸
General ▸ External Editor, or the system default (desktop: open -a / start / xdg-open,
spawned, never waited for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:58:19 -04:00
co-authored by Claude Opus 5.5
parent 0fafacfccd
commit 9affd0e8d2
11 changed files with 184 additions and 10 deletions
+1
View File
@@ -161,6 +161,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
open_with: None,
open_url: Some(Box::new(|url: &str| {
let w = web_sys::window().ok_or("no window")?;
w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string())
+22
View File
@@ -95,6 +95,28 @@ fn save_prefs(app: &LightcraftApp) {
fn services() -> Services {
Services {
pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))),
open_with: Some(Box::new(|path: &str, app: &str| {
// spawned, never waited for: the editor runs alongside
let app = app.trim();
let mut c = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
if !app.is_empty() {
c.args(["-a", app]);
}
c
} else if app.is_empty() {
if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
} else {
std::process::Command::new("xdg-open")
}
} else {
std::process::Command::new(app)
};
c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string())
})),
open_url: Some(Box::new(|url: &str| {
if !url.starts_with("https://") {
return Err("only https links are opened".into());
+58 -1
View File
@@ -7,7 +7,7 @@ use std::path::Path;
use lightcraft_catalog::{MediaKind, Op, Source};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
use super::{CommandSpec, bad, cmd, has_active, has_selection};
use crate::{Result, Session};
/// A free `<stem>.dng` (then `<stem>-2.dng`…) next to `path`.
@@ -102,3 +102,60 @@ pub fn specs() -> Vec<CommandSpec> {
convert
)]
}
/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit
/// TIFF next to the original (`<name>-Edit.tif`, never overwriting), add it to the library and
/// stack it on top of the original. The host opens the file in the editor.
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "photo.editExternal";
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad(C, "no photo"))?;
let dir = match &ph.source {
Source::File { path } => Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(),
Source::Demo { .. } => match super::str_param(p, "dir") {
Some(d) => d.to_string(),
None => return Err(bad(C, "a generated demo photo has no folder: give `dir`")),
},
};
let space = match super::str_param(p, "colorSpace").unwrap_or("adobeRgb") {
"srgb" => lightcraft_pipeline::OutputSpace::Srgb,
"displayP3" => lightcraft_pipeline::OutputSpace::DisplayP3,
"prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto,
_ => lightcraft_pipeline::OutputSpace::AdobeRgb,
};
let opts = crate::export::ExportOptions {
format: crate::export::ExportFormat::Tiff,
bit_depth: Some(16),
color_space: space,
naming: "{name}-Edit".into(),
conflict: crate::export::Conflict::Unique,
..Default::default()
};
let mut written = Vec::new();
let mut write = |path: &str, bytes: &[u8]| -> std::result::Result<(), String> {
std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
written.push(path.to_string());
Ok(())
};
let to = crate::export::Destination { dir, exact: None };
crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?;
let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?;
let r = s.execute("library.import", &json!({"paths": [out]}))?;
let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?;
// stack: the edit on top of the original, expanded so both show
let _ = s.execute("stack.group", &json!({"ids": [new, id.0], "top": new, "collapsed": false}));
s.selection = crate::Selection::single(lightcraft_catalog::PhotoId(new));
Ok(json!({"path": out, "id": new, "original": id.0}))
}
pub fn edit_specs() -> Vec<CommandSpec> {
vec![cmd!(
"photo.editExternal",
"Edit Copy for External Editor",
[],
None,
"{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `<name>-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)",
has_active,
edit_external
)]
}
+1
View File
@@ -122,6 +122,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(manage::specs());
v.extend(previews::specs());
v.extend(convert::specs());
v.extend(convert::edit_specs());
v.extend(merge::specs());
v.extend(query::specs());
v.extend(xmp::specs());
+26
View File
@@ -328,3 +328,29 @@ fn convert_raw_to_dng() {
assert_eq!(s.catalog.photo(id2).unwrap().format, "DNG");
let _ = std::fs::remove_dir_all(&dir);
}
/// Edit in External Editor (engine half): a 16-bit TIFF `-Edit` copy with the edits, next to
/// the original, added and stacked on top of it; a second one doesn't overwrite the first.
#[test]
fn external_edit_copy_is_stacked() {
let dir = temp_dir("extedit");
let src = dir.join("beach.png");
write_png(&src, 9);
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let orig = s.active().unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
let r = s.execute("photo.editExternal", &json!({})).unwrap();
let path = r["path"].as_str().unwrap().to_string();
assert!(path.ends_with("beach-Edit.tif"), "{path}");
let bytes = std::fs::read(&path).unwrap();
let d = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
assert_eq!(d.bit_depth, 16);
let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
assert_eq!(s.active(), Some(new));
let st = s.catalog.stack_of(new).expect("stacked");
assert_eq!((st.top(), st.photos.contains(&orig)), (new, true));
let r2 = s.execute("photo.editExternal", &json!({"colorSpace": "proPhoto"})).unwrap();
assert!(r2["path"].as_str().unwrap().ends_with("beach-Edit-Edit.tif") || r2["path"].as_str().unwrap().contains("-2"), "{r2}");
let _ = std::fs::remove_dir_all(&dir);
}
+4
View File
@@ -46,6 +46,8 @@ pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a URL in the user's browser.
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>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
@@ -67,6 +69,8 @@ pub struct Services {
pub pick_folder: Option<PickFolder>,
/// Open a web link in the browser (Help menu, About, Discord button).
pub open_url: Option<OpenUrlFn>,
/// Open a file in an external editor (Edit in External Editor; desktop only).
pub open_with: Option<OpenWithFn>,
}
#[derive(Clone, Copy, Debug, Default)]
+23 -1
View File
@@ -90,7 +90,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("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…", Some("Cmd+Shift+E"), "File"),
("dialog.export", "Export…", None, "File"),
("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"),
("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"),
@@ -669,6 +670,27 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::About);
Ok(Value::Null)
}
"photo.editInExternal" => {
// render an edit copy (stacked on the original), then open it in the editor
let mut params = p.clone();
if !params.is_object() {
params = json!({});
}
let r = match app.session.execute("photo.editExternal", &params) {
Ok(r) => r,
Err(e) => return Some(Err(e.to_string())),
};
let path = r["path"].as_str().unwrap_or_default().to_string();
let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone());
if let Some(f) = app.services.open_with.as_mut()
&& let Err(e) = f(&path, &editor)
{
app.toast(&ctx, format!("Couldn't open the editor: {e}"));
}
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
Ok(r)
}
"app.systemInfo" => {
let info = app.session.execute("library.info", &json!({})).unwrap_or_default();
let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten();
+10
View File
@@ -121,6 +121,16 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, "Culling");
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
heading(ui, t, "External Editor");
row(ui, t, "Application", |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text("System default").desired_width(220.0));
register(ui.ctx(), "field:externalEditor", r.rect);
});
hint(
ui,
t,
"Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).",
);
}
// -------------------------------------------------------------------------------------- Import
+3
View File
@@ -138,6 +138,8 @@ pub struct AppSettings {
pub gpu: bool,
/// Largest long edge (pixels) the loupe renders at.
pub preview_edge: u32,
/// Edit in External Editor: the application ("" = the system's default for TIFF files).
pub external_editor: String,
/// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`).
pub memory_mb: u32,
/// Filmstrip: file names above the thumbnails.
@@ -156,6 +158,7 @@ impl Default for AppSettings {
confirm_delete: false,
gpu: true,
preview_edge: 2560,
external_editor: String::new(),
memory_mb: 0,
film_names: true,
film_badges: true,
+28
View File
@@ -418,3 +418,31 @@ fn geometry_slider_drag_marks_the_grid() {
assert!(h.app.ui.dragging_control.is_none());
h.settle(SETTLE);
}
#[test]
fn edit_in_external_editor_opens_the_copy() {
let dir = std::env::temp_dir().join(format!("lc-ui-ext-{}", 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(())
})),
..Default::default()
};
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.app.ui.settings.external_editor = "PhotoCraft".into();
let r = h.request("engine.execute", json!({"command": "photo.editInExternal", "params": {"dir": dir.to_string_lossy()}}), T);
assert_eq!(r["ok"], true, "{r}");
let calls = opened.lock().unwrap().clone();
assert_eq!(calls.len(), 1);
assert!(calls[0].0.ends_with("-Edit.tif") && std::path::Path::new(&calls[0].0).exists(), "{calls:?}");
assert_eq!(calls[0].1, "PhotoCraft");
let _ = std::fs::remove_dir_all(&dir);
h.settle(SETTLE);
}
+8 -8
View File
@@ -39,18 +39,18 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — |
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
| S. Export (EXP) | 14 | 0 | 4 | 0 | 7/7 (100%) | 7/7 (100%) |
| S. Export (EXP) | 15 | 0 | 3 | 0 | 7/7 (100%) | 7/7 (100%) |
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) |
| Y. Menus | 76 | 2 | 7 | 8 | 46/47 (98%) | 22/23 (96%) |
| Z. Keyboard shortcuts (desktop) | 73 | 3 | 4 | 1 | 49/52 (94%) | 22/23 (96%) |
| Y. Menus | 77 | 2 | 6 | 8 | 46/47 (98%) | 22/23 (96%) |
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
| Lightroom Classic extras | 20 | 11 | 48 | 9 | — | 18/21 (86%) |
| **Total** | 357 | 27 | 116 | 37 | 193/198 (97%) | 132/144 (92%) |
| **Total** | 360 | 27 | 113 | 37 | 193/198 (97%) | 132/144 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **74.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 24.7% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **74.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 26.6% of 158.
<!-- /parity:summary -->
## Top gaps
@@ -423,7 +423,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EXP-DNGOPT | DNG options | P2 | ⬜ | | |
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`) | file copied byte for byte, sidecar named after the output |
| LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | |
| LR-EXP-PSD | Round trip to an external editor | P2 | ⬜ | | |
| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) |
## T. Share (SHARE)
@@ -515,7 +515,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 | ⬜ | | |
| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInExternal` | Photo ▸ Edit in External Editor |
| 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) |
@@ -608,7 +608,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-UNDOREDO | Undo / redo — ⌘Z / ⇧⌘Z | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | |
| KEY-MINIMIZE | Minimize — ⌘M | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native Window ▸ Minimize |
| KEY-AUTO | Auto — ⇧A | P0 | ✅ | `cmd:develop.auto` | |
| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ⬜ | | key used by our export dialog |
| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ✅ | `cmd:photo.editInExternal` | ⇧⌘E as observed; the export dialog is ⇧E |
| KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
| KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut` | |
| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | |