diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index debe6f68c..56869d1ff 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -446,6 +446,32 @@ pub fn specs() -> Vec { cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), + // ---- face / pet regions + cmd!( + "photo.removeRegion", + "Remove Face Box", + [], + None, + "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", + always, + |s, p| { + let id = + p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; + let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); + if index >= meta.regions.len() { + return Err(bad("photo.removeRegion", "no such region")); + } + let gone = meta.regions.remove(index); + s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"removed": gone.name})) + } + ), // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68200a356..da624241f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -125,6 +125,9 @@ pub struct Session { pub undo: Vec, pub redo: Vec, pub interaction: Option, + /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on + /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. + pub(crate) skip_auto_write: bool, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -223,6 +226,7 @@ impl Session { undo: Vec::new(), redo: Vec::new(), interaction: None, + skip_auto_write: false, clipboard: None, meta_clipboard: None, browse: None, @@ -308,8 +312,11 @@ impl Session { self.journal.drain(..1000); } } - if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { - self.auto_write_sidecars(&self.pending_log[log_start..]); + if self.depth == 0 { + let skip = std::mem::take(&mut self.skip_auto_write); + if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { + self.auto_write_sidecars(&self.pending_log[log_start..]); + } } if self.depth == 0 && self.library.is_some() { // Make the command durable before reporting success. When the command's own records diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 5b2919d0b..2c0f8f045 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -218,6 +218,36 @@ fn regions_from_sidecar_are_read_on_import() { let _ = std::fs::remove_dir_all(&src); } +/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's +/// regions and everything else in it stay byte for byte), even with auto-write on. +#[test] +fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() { + let src = temp_dir("region-remove"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(src.join("portrait.xmp")).unwrap(); + let n = s.catalog.photo(id).unwrap().meta.regions.len(); + assert!(n >= 1); + assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range"); + assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index"); + let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap(); + assert_eq!(r["removed"], "Jane Doe"); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1); + assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched"); + // an ordinary edit still auto-writes (the guard is per command) + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits"); + // undo brings the region back + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n); + let _ = std::fs::remove_dir_all(&src); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 58c90c241..b4afd8c1b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -78,6 +78,8 @@ pub enum Icon { /// Community chat (opens the ArtCraft Discord): a speech bubble with three dots. Our own /// generic drawing, not any service's logo. Chat, + /// Face boxes on/off: four corner brackets round a small face. + FaceBox, } struct Pen<'a> { @@ -404,6 +406,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 18.0, 6.5, 200.0, 340.0); pen.rect(2.5, 2.5, 17.5, 17.5, 2.0); } + FaceBox => { + pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]); + pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]); + pen.line(&[(2.5, 13.0), (2.5, 17.5), (7.0, 17.5)]); + pen.line(&[(13.0, 17.5), (17.5, 17.5), (17.5, 13.0)]); + pen.circle(10.0, 8.5, 2.4); + pen.arc(10.0, 16.0, 4.5, 215.0, 325.0); + } Picker => { pen.line(&[(4.0, 16.0), (11.5, 8.5)]); pen.closed(&[(11.0, 5.0), (15.0, 9.0), (17.0, 7.0), (13.0, 3.0)]); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fce32157a..65bbce425 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -123,6 +123,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "view.leftPanel", "view.photoCounts", + "view.faceBoxes", "view.filmstrip", "view.histogram", "view.navigator", @@ -284,6 +285,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.people" => Some(u.view == ViewMode::People), "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), + "view.faceBoxes" => Some(u.face_boxes), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a4a8ced9d..7f38e1af8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -22,6 +22,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.compare", "Compare", Some("Shift+C"), "View"), ("view.survey", "Survey", Some("N"), "View"), ("view.people", "People", None, "View"), + ("view.faceBoxes", "Face Boxes", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -264,6 +265,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.faceBoxes" => { + app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); + Ok(json!({"show": app.ui.face_boxes})) + } "view.people" => { app.ui.view = ViewMode::People; Ok(json!({"people": app.session.catalog.people().len()})) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 049a8fbd6..e36cba6a5 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -166,6 +166,11 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { { let _ = app.run("view.beforeAfter", json!({})); } + if app.ui.view == ViewMode::Detail + && icon_button(&mut child, "faceBoxes", Icon::FaceBox, vec2(30.0, 30.0), app.ui.face_boxes, true, "Face boxes").clicked() + { + let _ = app.run("view.faceBoxes", json!({})); + } if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { let _ = app.run("view.filmstrip", json!({})); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 262f41bbb..0fe2e5925 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -333,7 +333,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); info_overlay(app, &p, canvas, &photo); - region_overlay(&p, &map, &photo); + if app.ui.face_boxes + && let Some(index) = region_overlay(ui, &p, &map, &photo) + { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; @@ -421,16 +425,34 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } -/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Read-only — -/// LightCraft doesn't write regions yet. -fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) { +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows +/// a × in its corner; clicking it returns that region's index so the caller can remove it (a +/// catalog-only edit: LightCraft doesn't write regions to XMP). +fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option { let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); - for r in &photo.meta.regions { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let mut remove = None; + for (index, r) in photo.meta.regions.iter().enumerate() { let rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.y1))); // white with a black keyline just outside it, so the box shows on any background p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); + if pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { + let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.top() + 11.0), vec2(16.0, 16.0)); + let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); + register(ui.ctx(), format!("regionRemove:{index}"), xr); + let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); + p.rect_filled(xr, 2.0, fill); + p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); + let (c, m) = (xr.center(), 3.5); + let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() { + remove = Some(index); + } + } let Some(name) = &r.name else { continue }; // the name in a dark label with a caret, centred above the box (below it when there is no room) let g = p.layout_no_wrap(name.clone(), t.font(13.0), Color32::from_gray(225)); @@ -451,6 +473,7 @@ fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); p.galley(label.min + pad, g, Color32::from_gray(225)); } + remove } /// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e14cabb5..04567c41f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -264,6 +264,8 @@ pub struct UiState { pub grid_info: String, /// Photo counts next to sources and albums in the left panel. pub show_counts: bool, + /// Face / pet boxes (read from XMP) over the photo in the loupe. + pub face_boxes: bool, /// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched). pub hidden_locations: Vec, /// Copies opened in an external editor this session (reloaded when the window is focused @@ -550,6 +552,7 @@ impl Default for UiState { show_filenames: true, grid_info: "filename".into(), show_counts: true, + face_boxes: true, hidden_locations: Vec::new(), dragging_control: None, external_edits: Vec::new(), diff --git a/docs/parity.md b/docs/parity.md index b29c5e069..cd9aedab0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) and removed with the × shown on hover (catalog-only and undoable; the sidecar is never rewritten for it). No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |