diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 54cd703e8..f112fb33c 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -161,6 +161,7 @@ fn services(originals: Originals, backend: Option, 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()) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index bb505abba..d6c56145e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -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()); diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index f10f55c94..ba85e0d8e 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -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 `.dng` (then `-2.dng`…) next to `path`. @@ -102,3 +102,60 @@ pub fn specs() -> Vec { convert )] } + +/// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit +/// TIFF next to the original (`-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 { + 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 { + 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 `-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 + )] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6893d2186..f6096c017 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 82cad6a9d..3cb7d4cb4 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -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); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 202ea3399..ae422c6db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -46,6 +46,8 @@ pub type PickFolder = Box Option>; pub type RevealFn = Box Result<(), String>>; /// Open a URL in the user's browser. 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>>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] @@ -67,6 +69,8 @@ pub struct Services { pub pick_folder: Option, /// Open a web link in the browser (Help menu, About, Discord button). pub open_url: Option, + /// Open a file in an external editor (Edit in External Editor; desktop only). + pub open_with: Option, } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fe72dfbbb..129eec978 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + // 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", ¶ms) { + 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(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2504e9a23..5990c522c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 91f78b1d6..c7dc69635 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 7af058f72..0bad2a637 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -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>> = 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); +} diff --git a/docs/parity.md b/docs/parity.md index 041ef3d3f..86122d4c4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. ## 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` | |