From 0571d8e43830a0dbb5c25d4ed2ea964174e2b6fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:58:36 -0400 Subject: [PATCH] =?UTF-8?q?M9.7:=20virtual=20copies=20=E2=80=94=20new=20ca?= =?UTF-8?q?talog=20entries=20sharing=20the=20original=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photo.copy_of / copy_name. photo.virtualCopy (Cmd+') clones the photo with independent settings and history, names it "Copy N" (numbered per master), adds it to the original's albums and stacks it (expanded) right after the original; one undo step for several copies (Session::merge_undo). Copies never write XMP sidecars. "Copy N" badge in the grid, Info panel line, copy:yes search token. Also: Info panel stars laid out in a row. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 9 ++++ crates/catalog/src/query.rs | 1 + crates/catalog/src/tests_journal.rs | 14 +++++- crates/engine/src/cmd/organize.rs | 69 ++++++++++++++++++++++++++++- crates/engine/src/cmd/query.rs | 2 + crates/engine/src/lib.rs | 11 +++++ crates/engine/src/sidecar.rs | 5 ++- crates/engine/src/tests_organize.rs | 46 +++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 20 +++++++++ crates/ui-egui/src/panels/right.rs | 6 ++- crates/ui-egui/src/shortcuts.rs | 1 + 11 files changed, 179 insertions(+), 5 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index c27eefa9..99d35294 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -174,6 +174,13 @@ pub struct Photo { /// "Enable Profile Corrections" is on. #[serde(default, skip_serializing_if = "Option::is_none")] pub embedded_lens: Option, + /// A virtual copy: the photo it was copied from (it shares that photo's file but has its own + /// settings, metadata and history). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_of: Option, + /// Virtual copies' name ("Copy 1"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_name: Option, } impl Photo { @@ -202,6 +209,8 @@ impl Photo { as_shot_wb: None, content_hash: None, embedded_lens: None, + copy_of: None, + copy_name: None, } } pub fn is_edited(&self) -> bool { diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 416b996a..389c01e3 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -97,6 +97,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), + "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), _ => false, }; diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index d04427ba..01d1f03d 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -26,7 +26,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() }, ..Default::default() }; - match kind % 15 { + match kind % 16 { 0 | 1 => { let id = c.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01"); @@ -67,6 +67,18 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { } 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), + 15 => match c.photo(pid).cloned() { + // a virtual copy: same source, own id, copy_of/copy_name set + Some(src) => { + let id = c.alloc_photo_id(); + let mut v = (*src).clone(); + v.id = id; + v.copy_of = Some(pid); + v.copy_name = Some(format!("Copy {b}")); + Op::AddPhoto { photo: Box::new(v) } + } + None => Op::Batch { ops: vec![] }, + }, _ => c.delete_permanently_ops(pid), } } diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 8b4b6f73..255fc088 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -1,7 +1,7 @@ //! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture -//! time). +//! time) and virtual copies. -use lightcraft_catalog::{Op, PhotoId, StackId}; +use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection}; @@ -58,8 +58,73 @@ fn set_collapsed(s: &mut Session, stacks: Vec, collapsed: bool, label: Ok(json!({"changed": n})) } +/// "Copy 3" → 3. +fn copy_number(name: Option<&str>) -> u32 { + name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0) +} + +/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and +/// metadata, added to the same albums and stacked right after it (the stack is expanded so the +/// copy is visible). +fn virtual_copy(s: &mut Session, src: PhotoId) -> Result> { + let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) }; + let master = orig.copy_of.unwrap_or(src); + let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0); + let id = s.catalog.alloc_photo_id(); + let mut c = (*orig).clone(); + c.id = id; + c.copy_of = Some(master); + c.copy_name = Some(format!("Copy {n}")); + c.versions.clear(); + c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }]; + c.deleted = false; + let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }]; + for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) { + let mut photos = a.photos.clone(); + let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1); + photos.insert(at, id); + ops.push(Op::SetAlbumPhotos { id: a.id, photos }); + } + match s.catalog.stack_of(src).cloned() { + Some(st) => { + let mut photos = st.photos.clone(); + let at = st.position(src).map_or(photos.len(), |i| i + 1); + photos.insert(at, id); + ops.push(Op::SetStack { id: st.id, photos, collapsed: false }); + } + None => { + let sid = s.catalog.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } }); + } + } + s.commit("Create Virtual Copy", Op::Batch { ops })?; + Ok(Some(id)) +} + pub fn specs() -> Vec { vec![ + cmd!( + "photo.virtualCopy", + "Create Virtual Copy", + ["Photo"], + Some("Cmd+'"), + "{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"", + has_selection, + |s, p| { + let targets = s.targets(p); + let mut made = Vec::new(); + for t in targets { + if let Some(id) = virtual_copy(s, t)? { + made.push(id); + } + } + s.merge_undo(made.len(), "Create Virtual Copy"); + if let Some(last) = made.last() { + s.selection = Selection { ids: made.clone(), active: Some(*last) }; + } + Ok(json!({"ids": made.iter().map(|i| i.0).collect::>()})) + } + ), cmd!( "stack.group", "Group into Stack", diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index db4be842..bed6883d 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -24,6 +24,8 @@ pub fn photo_summary(p: &Photo) -> Value { "keywords": p.meta.keywords, "camera": p.meta.camera, "deleted": p.deleted, + "copyOf": p.copy_of.map(|c| c.0), + "copyName": p.copy_name, }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 39b1dc74..71f2fe37 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -182,6 +182,17 @@ impl Session { Ok(()) } + /// Fold the last `n` undo steps into one (commands that commit step by step because each op + /// depends on the state the previous one left). + pub fn merge_undo(&mut self, n: usize, label: &str) { + if n < 2 || n > self.undo.len() { + return; + } + let tail = self.undo.split_off(self.undo.len() - n); + let ops = tail.into_iter().rev().map(|e| e.op).collect(); + self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } }); + } + /// Apply without recording undo (interactive previews). fn apply_silent(&mut self, op: Op) -> Result<()> { self.catalog.apply(op)?; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c84c3eb1..e62d2b09 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -241,6 +241,9 @@ impl Session { /// Write the photo's XMP sidecar; returns its path. pub fn save_sidecar(&self, id: PhotoId) -> Result { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; + if p.copy_of.is_some() { + return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); + } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; let path = sidecar_path(orig, self.xmp.naming); write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; @@ -272,7 +275,7 @@ impl Session { let mut ids = Vec::new(); ops.iter().for_each(|o| op_photos(o, &mut ids)); for id in ids { - if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some()) + if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) && let Err(e) = self.save_sidecar(id) { log::warn!("auto-write XMP: {e}"); diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 2930ec68..9c0b73cf 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -79,6 +79,52 @@ fn smart_album_from_smart_album_view_keeps_its_rules() { assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } +#[test] +fn virtual_copies_are_independent_and_persist() { + let dir = temp_dir("vc"); + let mut s = open(&dir); + let src = s.visible_cloned()[1]; + let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap(); + s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + let undo_before = s.undo.len(); + let r = s.execute("photo.virtualCopy", &json!({})).unwrap(); + assert_eq!(s.undo.len(), undo_before + 1); + let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap()); + let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap(); + let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap()); + let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone()); + assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1"))); + assert_eq!(b.copy_name.as_deref(), Some("Copy 2")); + assert_eq!(a.source, p.source, "shares the original file"); + assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings"); + // a copy of a copy names itself after the master + let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap(); + let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone(); + assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3"))); + // independent settings + s.execute("library.select", &json!({"ids": [c1.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); + assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5); + assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0); + // same album, stacked (expanded) with the original, visible in the grid + assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id])); + assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]); + assert!(s.visible_cloned().contains(&c2)); + let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap(); + assert_eq!(found["total"], 3); + // undo removes a copy completely + s.execute("edit.undo", &json!({})).unwrap(); // exposure + s.execute("edit.undo", &json!({})).unwrap(); // third copy + assert!(s.catalog.photo(c3.id).is_none()); + let expect = s.catalog.to_snapshot(); + drop(s); + let s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn stacks_group_collapse_and_replay() { let dir = temp_dir("stacks"); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 8bad7394..309254fe 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -198,6 +198,23 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label); } } + if let Some(name) = &photo.copy_name { + // virtual copy: a folded-corner tag at the top right + let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE); + let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; + let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); + p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); + let c = pos2(br.left() + 13.0, br.center().y); + let s = 5.0; + p.add(egui::Shape::convex_polygon( + vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], + Color32::TRANSPARENT, + Stroke::new(1.2, Color32::WHITE), + )); + p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + } if photo.flag == Flag::Reject { p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); } @@ -280,6 +297,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + if ui.button("Create Virtual Copy").clicked() { + let _ = app.run("photo.virtualCopy", json!({})); + } ui.menu_button("Stack", |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 45e65291..cf0321d5 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -243,8 +243,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text)); ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim)); + if let Some(name) = &p.copy_name { + let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into()); + ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label)); + } ui.add_space(8.0); - if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) { + if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { let _ = app.run("photo.rate", json!({"rating": r})); } }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index af53a4bd..193a36c5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -27,6 +27,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { "-" => Some(Key::Minus), "[" => Some(Key::OpenBracket), "]" => Some(Key::CloseBracket), + "'" => Some(Key::Quote), _ => None, }) }