mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
0fafacfccd
commit
9affd0e8d2
@@ -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())
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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", ¶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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
Reference in New Issue
Block a user