mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 04:57:22 -06:00
Faces: switch face boxes on/off and remove one with the × shown on hover
View > Face Boxes (and a toolbar button in the loupe) hides or shows the boxes.
Hovering a box shows a × in its corner; clicking it runs the new undoable
`photo.removeRegion {id?, index}`, a catalog-only edit. The XMP sidecar is
never rewritten for it, even with auto-write on (a `skip_auto_write` flag
consumed when the command ends), because the sidecar writer builds a fresh
packet that does not carry the regions: reading the metadata from the file
brings a removed region back. A test with a real sidecar proves the file stays
byte for byte, that ordinary edits still auto-write, and that undo restores it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
b9621eb5a3
commit
e2e6db0f14
@@ -446,6 +446,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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 }))?;
|
||||
|
||||
@@ -125,6 +125,9 @@ pub struct Session {
|
||||
pub undo: Vec<UndoEntry>,
|
||||
pub redo: Vec<UndoEntry>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// 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<Value>,
|
||||
/// 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
|
||||
|
||||
@@ -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<u8> {
|
||||
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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<bool> {
|
||||
"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),
|
||||
|
||||
@@ -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<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.compare" => 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()}))
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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<usize> {
|
||||
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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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(),
|
||||
|
||||
+1
-1
@@ -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" |
|
||||
|
||||
Reference in New Issue
Block a user