diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 81f949458..f106d7706 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -503,18 +503,32 @@ pub fn specs() -> Vec { Ok(json!({"groups": s.copy_groups})) } ), - cmd!("develop.paste", "Paste Edit Settings", ["Edit"], Some("Cmd+V"), "{ids?}", has_clipboard, |s, p| { - let clip = s.clipboard.clone().unwrap_or_default(); - let ids = s.targets(p); - let ops = ids - .iter() - .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) - .filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &clip, 1.0), "Paste Settings")) - .collect::>(); - let n = ops.len(); - s.commit("Paste Settings", Op::Batch { ops })?; - Ok(json!({"changed": n})) - }), + cmd!( + "develop.paste", + "Paste Edit Settings", + ["Edit"], + Some("Cmd+V"), + "{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}", + has_clipboard, + |s, p| { + let mut clip = s.clipboard.clone().unwrap_or_default(); + if let Some(only) = groups_param(p) { + // the clipboard holds whole groups: rebuild it with just the chosen (copied) ones + let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect(); + let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0); + clip = lightcraft_develop::extract_groups(&full, &only); + } + let ids = s.targets(p); + let ops = ids + .iter() + .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) + .filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &clip, 1.0), "Paste Settings")) + .collect::>(); + let n = ops.len(); + s.commit("Paste Settings", Op::Batch { ops })?; + Ok(json!({"changed": n})) + } + ), cmd!( "develop.sync", "Sync Settings", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9b6e8bd6a..fa68ba610 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -209,3 +209,22 @@ fn memory_report_counts_decoded_sources_and_renders() { assert_eq!(m.thumb_sources.count, 1); assert!(m.rendered.count >= 1, "the thumbnail render is cached"); } + +#[test] +fn paste_selected_settings_pastes_only_chosen_copied_groups() { + let mut s = demo(); + let ids: Vec<_> = s.visible().iter().copied().take(2).collect(); + s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30, "detail.sharpenAmount": 90}})).unwrap(); + s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap(); + // `detail` was not copied: asking for it pastes nothing for it + s.execute("develop.paste", &json!({"ids": [ids[1].0], "groups": ["light", "detail"]})).unwrap(); + let d = s.develop_of(ids[1]).unwrap(); + let base = lightcraft_develop::DevelopSettings::default(); + assert_eq!(d.light.exposure, 1.0); + assert_eq!(d.color.vibrance, base.color.vibrance); + assert_ne!(d.detail.sharpen_amount, 90.0); + // without groups: everything copied + s.execute("develop.paste", &json!({"ids": [ids[1].0]})).unwrap(); + assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a72b9e4c9..aeed7dc16 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -550,6 +550,38 @@ mod tests { h.settle(SETTLE); } + /// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group + /// leaves it alone. ⌘F puts typing into the search field. + #[test] + fn paste_selected_settings_and_find() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}})); + exec(&mut h, "develop.copy", json!({"groups": ["light", "color"]})); + exec(&mut h, "library.select", json!({"ids": [ids[1]]})); + h.request("ui.key", json!({"key": "V", "cmd": true, "shift": true}), t); + h.settle(SETTLE); + let Some(crate::state::Dialog::PasteSettings { groups }) = &h.app.ui.dialog else { panic!("dialog not open: {:?}", h.app.ui.dialog) }; + assert_eq!(groups, &["light", "color"]); + let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:color"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.dialog.confirm", json!({}), t)["ok"], true); + let d = h.app.session.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 0.0)); + // ⌘F, then typing filters the grid by text + h.request("ui.set", json!({"view": "grid"}), t); + h.request("ui.key", json!({"key": "F", "cmd": true}), t); + h.settle(SETTLE); + h.request("ui.text", json!({"text": "zz-no-such-photo"}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.search, "zz-no-such-photo"); + assert!(h.app.session.visible_cloned().is_empty()); + } + /// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos. #[test] fn capture_time_dialog_shifts_time_zone() { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7c4614f5e..14936f988 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -80,10 +80,13 @@ const LAYOUT: &[(&str, &[&str])] = &[ "develop.copy", "dialog.copySettings", "develop.paste", + "dialog.pasteSettings", "---", "library.selectAll", "library.selectNone", "---", + "view.focusSearch", + "---", "app.settings", ], ), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d884f985d..bd352bd4d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -79,6 +79,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), ("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.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), @@ -511,6 +513,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::PasteSettings { groups }); + Ok(Value::Null) + } + "view.focusSearch" => { + // the search field lives in the top bar of the library and detail views alike + app.ui.focus_search = true; + Ok(Value::Null) + } "dialog.copySettings" => { let groups = app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); @@ -625,6 +638,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { app.session.active().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(), "app.showInFinder" => { app.services.reveal.is_some() && app diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a1bf07ae8..d48d6dd9d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -32,6 +32,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", + Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", @@ -233,6 +234,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { group_checklist(ui, "presetInclude", groups); } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), + Dialog::PasteSettings { groups } => { + let n = app.session.targets(&json!({})).len(); + ui.label( + egui::RichText::new(format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" })) + .color(t.text_dim), + ); + group_checklist(ui, "pasteGroup", groups); + } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; let n = app.session.selection.ids.len().max(1); @@ -589,6 +598,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("develop.copy", json!({"groups": groups})), + Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); p["dir"] = json!(dir); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 37175302c..9008515db 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -467,12 +467,22 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + // in a (non-smart) album: take the selection out of it + if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source + && app.session.catalog.album(aid).is_some_and(|a| !a.folder && !a.is_smart()) + && ui.button("Remove from Album").clicked() + { + let _ = app.run("album.removePhotos", json!({"id": aid.0})); + } if ui.button("Rename…").clicked() { let _ = app.run("dialog.rename", json!({})); } if ui.button("Create Virtual Copy").clicked() { let _ = app.run("photo.virtualCopy", json!({})); } + if ui.button("Create Version").clicked() { + let _ = app.run("version.create", json!({})); + } ui.menu_button("Stack", |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; @@ -499,6 +509,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Edit Settings")).clicked() { let _ = app.run("develop.paste", json!({})); } + if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Selected Settings…")).clicked() { + let _ = app.run("dialog.pasteSettings", json!({})); + } if ui.button("Reset Edits").clicked() { let _ = app.run("develop.reset", json!({})); } @@ -518,6 +531,26 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.rotateRight", json!({})); } ui.separator(); + if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new("Show in Finder")).clicked() { + let _ = app.run("app.showInFinder", json!({})); + } + if ui.button("Export…").clicked() { + let _ = app.run("dialog.export", json!({})); + } + ui.menu_button("Export with Preset", |ui| { + for (p, _) in app.session.all_export_presets() { + if ui.button(&p.name).clicked() { + match app.run("app.export", json!({"preset": p.name})) { + Ok(r) => { + let n = r["files"].as_array().map_or(0, Vec::len); + app.toast(ui.ctx(), format!("Exported {n} photo{}", if n == 1 { "" } else { "s" })); + } + Err(e) => app.toast(ui.ctx(), e), + } + } + } + }); + ui.separator(); if ui.button("Delete Photo").clicked() { let _ = app.run("photo.delete", json!({})); } diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 2958a9a5d..3ce3a4e5c 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -43,6 +43,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // search field (centred on the window) let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); let id = egui::Id::new("search-field"); + if std::mem::take(&mut app.ui.focus_search) { + ui.memory_mut(|m| m.request_focus(id)); + } let focused = ui.memory(|m| m.has_focus(id)); ui.painter().rect( sr, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c5a2a6922..8d994ce79 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -199,6 +199,9 @@ pub struct UiState { pub crop_overlay: CropOverlay, pub show_filenames: bool, pub search: String, + /// Focus the search field on the next frame (Edit → Find…). + #[serde(skip)] + pub focus_search: bool, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -329,6 +332,10 @@ pub enum Dialog { CopySettings { groups: Vec, }, + /// Paste Selected Settings: which of the copied groups to paste. + PasteSettings { + groups: Vec, + }, /// `resize` is used unless `full_size`; `limit_kb` 0 = no limit; `dir` empty = default export folder. Export { opts: lightcraft_engine::export::ExportOptions, @@ -382,6 +389,7 @@ impl Default for UiState { crop_overlay: CropOverlay::Thirds, show_filenames: true, search: String::new(), + focus_search: false, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/docs/parity.md b/docs/parity.md index d089ac559..8a4ee7294 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,10 +45,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) | -| Y. Menus | 54 | 17 | 14 | 8 | 39/47 (83%) | 13/23 (57%) | -| Z. Keyboard shortcuts (desktop) | 54 | 13 | 13 | 1 | 45/52 (87%) | 9/23 (39%) | +| Y. Menus | 56 | 16 | 13 | 8 | 41/47 (87%) | 13/23 (57%) | +| Z. Keyboard shortcuts (desktop) | 56 | 13 | 11 | 1 | 47/52 (90%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) | -| **Total** | 260 | 81 | 159 | 37 | 178/198 (90%) | 72/144 (50%) | +| **Total** | 264 | 80 | 156 | 37 | 182/198 (92%) | 72/144 (50%) | ## Top gaps @@ -85,9 +85,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | **Still missing / broken:** -- No command yet: F full-screen preview, ⇧⌘F window full screen, ⇧⌘V paste selected, - ⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, ⌃H / ⌃M merges, F1 help, - ⇧6–9 label + advance. +- No command yet: F1 help, ⇧6–9 label + advance (verify the rest of the old list: full screen, settings, stacks, + visualize spots and merges have commands now). - `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -522,11 +521,11 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | | MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | | MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | | -| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ⬜ | | | +| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted | | MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | | MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | | MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ⬜ | | | -| MENU-EDIT-FIND | Find… | P0 | 🟡 | `crates/ui-egui/src/panels/topbar.rs` | search field; no command to focus it | +| MENU-EDIT-FIND | Find… | P0 | ✅ | `cmd:view.focusSearch`, `crates/ui-egui/src/panels/topbar.rs` | ⌘F focuses the search field | | MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | | | MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | | MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | @@ -580,7 +579,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ⬜ | | | | MENU-HELP-SYSINFO | System info | P2 | ⬜ | `cmd:library.info` | library info only | -| MENU-CTX-GRID | Photo context menu | P0 | 🟡 | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, add to album, copy/paste, reset, rotate, delete; no label, remove from album, export, reveal, version, cover | +| MENU-CTX-GRID | Photo context menu | P0 | 🟡 | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, delete; no set-as-album-cover | | MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | 🟡 | `crates/ui-egui/src/panels/left.rs` | add selected, rename, delete; no move-to, export album | | MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` | add/subtract component; rename/duplicate by command only | @@ -612,9 +611,9 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut` | | | KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | | KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | -| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command | +| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ✅ | `cmd:dialog.pasteSettings` | | | KEY-PREFS | Settings — ⌘, | P0 | ✅ | `cmd:app.settings` | | -| KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command | +| KEY-SEARCH | Search — ⌘F | P0 | ✅ | `cmd:view.focusSearch` | | | KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | | | KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.cropOverlay` | O toggles the mask overlay; ⇧O cycles mask overlay modes while masking, crop overlays elsewhere | | KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | `cmd:view.leftPanel` | bound to ⌘⇧L; P = pick |