From 7a4e842dd58a5bc6e1dcba36488dbd994c050ea7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:51:15 -0700 Subject: [PATCH] LR-META-INFO: an Info field saves to the photos selected when its edit started The text fields and GPS saved with no `ids`, so `photo.setMeta` wrote to whatever was selected when the edit ended. A selection change during an edit then gave the typed text to the new selection. Each edit now keeps the selected photos from its start and saves to them. --- crates/ui-egui/src/headless.rs | 41 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 18 ++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index fe4e255d4..b7343b169 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3078,6 +3078,47 @@ mod tests { assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into())); } + /// An Info field saves to the photos selected when its edit started, also when the selection + /// changes while the field is being edited. + #[test] + fn info_field_saves_to_the_photos_selected_when_the_edit_started() { + // (tall enough for the GPS field to show without scrolling) + let mut h = demo([1300.0, 1800.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(4).map(|p| p.0).collect(); + let meta = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "title": "Same", "gps": ""})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + exec(&mut h, "library.select", json!({"ids": [ids[2]]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).title, meta(&h, 1).title, meta(&h, 2).title), ("Same!".into(), "Same!".into(), "Same".into())); + // GPS too (the new selection is two photos as well, so the panel keeps its layout) + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:gps"})); + ask(&mut h, "ui.text", json!({"text": "41.15, -8.61"})); + exec(&mut h, "library.select", json!({"ids": [ids[2], ids[3]], "active": ids[2]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let gps: Vec = (0..4).map(|i| meta(&h, i).gps.is_some()).collect(); + assert_eq!(gps, [true, true, false, false]); + } + /// Local: a folder's photos show without joining the library; the breadcrumb, Include /// subfolders and Add to My Photos work from the grid header. #[test] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index e5a454559..6da043831 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -756,7 +756,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; let gid = egui::Id::new("info-gps"); ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); - let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &selected)); let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); let r = ui.add(egui::TextEdit::singleline(&mut edit.text).hint_text(hint).desired_width(f32::INFINITY)); crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); @@ -770,7 +770,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let text = edit.text.trim(); if r.lost_focus() && (text != edit.start || edit.clears()) - && let Err(e) = app.run("photo.setMeta", json!({"gps": text})) + && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) { app.toast(ui.ctx(), e); } @@ -915,11 +915,14 @@ const MIXED: &str = "Mixed"; /// An Info panel field's edit under way, kept in egui memory between frames. A field saves when /// its text is not the text the edit started from, so a field left as it was saves nothing, even -/// when what the panel shows changed during the edit. +/// when what the panel shows changed during the edit. It saves to the photos selected when the +/// edit started, also when the selection changed since. #[derive(Clone)] struct FieldEdit { text: String, start: String, + /// The selected photos when the edit started (`photo.setMeta` `ids`). + ids: Vec, /// The field said Mixed when the edit started. mixed: bool, /// Something was typed or deleted since. @@ -927,8 +930,9 @@ struct FieldEdit { } impl FieldEdit { - fn new(value: &str, mixed: bool) -> Self { - FieldEdit { text: value.to_string(), start: value.to_string(), mixed, typed: false } + fn new(value: &str, mixed: bool, selected: &[PhotoId]) -> Self { + let ids = selected.iter().map(|id| id.0).collect(); + FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false } } /// A Mixed field that was typed in and left empty: it clears the field on every selected photo. @@ -1053,7 +1057,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::new(("info-field", key)); let value = if mixed { "" } else { value }; - let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, &app.session.selected())); let widget = format!("field:{key}"); let field = if lines > 1 { crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) @@ -1071,7 +1075,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str } // Return (one line) or leaving saves; Esc gives the edit up if r.committed() && (edit.text != edit.start || edit.clears()) { - let _ = app.run("photo.setMeta", json!({key: edit.text})); + let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text})); } ui.add_space(6.0); }