MENU-EDIT-PASTESELECTED + KEY-SEARCH: paste selected settings, Find, fuller photo context menu

- Edit → Paste Selected Settings… (⇧⌘V): group checklist prefilled with the copied groups;
  develop.paste {groups} pastes only the chosen copied groups.
- Edit → Find… (⌘F, view.focusSearch) focuses the search field.
- Photo context menu: Remove from Album (in an album), Create Version, Paste Selected
  Settings…, Show in Finder, Export…, Export with Preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 22:00:24 -04:00
co-authored by Claude Opus 5.5
parent faa5478966
commit 8548c157ba
10 changed files with 158 additions and 23 deletions
+26 -12
View File
@@ -503,18 +503,32 @@ pub fn specs() -> Vec<CommandSpec> {
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::<Vec<_>>();
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::<Vec<_>>();
let n = ops.len();
s.commit("Paste Settings", Op::Batch { ops })?;
Ok(json!({"changed": n}))
}
),
cmd!(
"develop.sync",
"Sync Settings",
+19
View File
@@ -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);
}
+32
View File
@@ -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<u64> = 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() {
+3
View File
@@ -80,10 +80,13 @@ const LAYOUT: &[(&str, &[&str])] = &[
"develop.copy",
"dialog.copySettings",
"develop.paste",
"dialog.pasteSettings",
"---",
"library.selectAll",
"library.selectNone",
"---",
"view.focusSearch",
"---",
"app.settings",
],
),
+14
View File
@@ -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<Re
app.ui.dialog = Some(Dialog::create_preset());
Ok(Value::Null)
}
"dialog.pasteSettings" => {
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
+10
View File
@@ -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<serde_jso
}),
),
Dialog::CopySettings { groups } => 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);
+33
View File
@@ -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!({}));
}
+3
View File
@@ -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,
+8
View File
@@ -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<String>,
},
/// Paste Selected Settings: which of the copied groups to paste.
PasteSettings {
groups: Vec<String>,
},
/// `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(),
+10 -11
View File
@@ -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%) |
<!-- /parity:summary -->
## 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 |