From 0b1b47ee7b3c32e06a587a696073a527d78850cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 06:22:46 -0700 Subject: [PATCH 1/4] LR-META-INFO: the Info panel can show metadata for the selected photos With several photos selected, the Info panel offers Show Metadata For: Target Photo (the default) shows the active photo's values as before, and Selected Photos shows the values the selected photos share. A text field or GPS where they differ is empty with a Mixed hint, and Copyright Status shows Mixed. What is typed there is saved to every selected photo, and a Mixed field typed in and left empty clears it on all of them. A field left as it was saves nothing, even when the choice changes during the edit. The choice is saved with the app settings, and `view.infoSelectedPhotos {on?}` sets it for an agent. --- crates/ui-egui/src/headless.rs | 145 ++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 12 ++ crates/ui-egui/src/panels/right.rs | 174 ++++++++++++++++++++++++----- crates/ui-egui/src/state.rs | 5 + docs/control-protocol.md | 6 + docs/gaps.md | 5 +- docs/parity-checklist.md | 2 +- 7 files changed, 320 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a5d05edac..e06903fb0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2902,6 +2902,151 @@ mod tests { assert_eq!(meta(&h).title, "Wedding in Sintra"); } + /// With several photos selected the Info panel offers Target Photo / Selected Photos. Target + /// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose + /// values differ empty, so what is typed there is what every selected photo gets, and keeps + /// the value of a field they share; leaving a mixed field untouched saves nothing. + #[test] + fn info_shows_metadata_for_the_selected_photos() { + let mut h = demo([1300.0, 1000.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(); + r + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone() + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1])); + let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + h.settle(SETTLE); + let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string(); + assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected"); + assert!(!h.app.ui.settings.info_selected_photos, "Target Photo by default"); + // Target Photo: the title field holds the active photo's title, and what is typed after it + // goes to both photos + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title), ("Dawn!".into(), "Dawn!".into())); + // Selected Photos, from its button + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"})); + assert!(h.app.ui.settings.info_selected_photos); + // a mixed field left untouched saves nothing + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo)); + // neither does switching the choice while a field is being edited, either way + for on in [false, true] { + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": on})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}"); + } + // the mixed title starts empty; the shared creator keeps its value + for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] { + ask(&mut h, "ui.clickWidget", json!({"id": format!("field:{key}")})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": typed})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator }; + assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}"); + } + // a mixed field typed in and left empty clears it on every selected photo + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + ask(&mut h, "ui.text", json!({"text": "x"})); + ask(&mut h, "ui.key", json!({"key": "Backspace"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new())); + // GPS: one photo's place and Show on Map only while the selected photos share it + let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]})); + h.settle(SETTLE); + assert!(!map(&mut h), "places differ"); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]})); + h.settle(SETTLE); + assert!(map(&mut h), "a shared place"); + // the command: refuses a value that isn't true or false, and turns Target Photo back on + let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false); + assert!(!h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles"); + // the choice is saved with the app settings; settings saved before it read as Target Photo + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(saved["infoSelectedPhotos"], true); + let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap(); + assert!(read.info_selected_photos); + let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap(); + assert!(!old.info_selected_photos); + // one photo selected: no choice to make + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + h.settle(SETTLE); + assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h)); + } + + /// The fields the Info panel marks as mixed: each text field, the copyright status and GPS + /// where a selected photo differs from the photo the panel shows, and none for one photo. + #[test] + fn info_mixed_fields_are_the_ones_that_differ() { + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v}); + // (every field the panel compares starts the same on the three photos) + let fields = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "state", + "country", + ]; + for key in fields { + s.execute("photo.setMeta", &all(key, json!(""))).unwrap(); + } + for (key, v) in + [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] + { + s.execute("photo.setMeta", &all(key, v)).unwrap(); + } + let photos: Vec = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect(); + let shown = s.catalog.photo(photos[0]).unwrap().meta.clone(); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty()); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty()); + s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap(); + let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown); + mixed.sort_unstable(); + assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]); + } + /// 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/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..0b7de30a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -139,6 +139,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("local.addRoot", "Add Folder to Local", None, ""), ("local.hide", "Remove from Local", None, ""), ("local.restoreHidden", "Show Hidden Local Locations", None, ""), + // {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos) + ("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""), ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), @@ -1114,6 +1116,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {on?}; no params toggles. On: the Info panel shows what the selected photos share. + let on = match p.get("on") { + None | Some(Value::Null) => !app.ui.settings.info_selected_photos, + Some(Value::Bool(on)) => *on, + Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))), + }; + app.ui.settings.info_selected_photos = on; + Ok(json!({"on": on})) + } "view.toolOverlay" => { use crate::state::ToolOverlay; let mode = match p.get("mode") { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a29534be3..4b88aa20f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -675,7 +675,15 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); divider(ui); let m = p.meta.clone(); + // with several photos selected: Target Photo shows this photo's values, Selected Photos what + // the selection shares (a field where they differ is empty and says Mixed) + let selected = app.session.selected(); + let several = selected.len() > 1; + let mixed = if several && app.ui.settings.info_selected_photos { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; padded(ui, |ui| { + if several { + metadata_for(app, ui); + } for (label, key, value, lines) in [ ("Title", "title", &m.title, 1), ("Caption", "caption", &m.caption, 2), @@ -683,15 +691,15 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Extended Description", "extendedDescription", &m.extended_description, 3), ("Copyright", "copyright", &m.copyright, 1), ] { - meta_field(app, ui, label, key, value, lines); + meta_field(app, ui, label, key, value, lines, mixed.contains(&key)); } - copyright_status(app, ui, m.copyright_status); + copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus")); for (label, key, value, lines) in [ ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), ("Creator", "creator", &m.creator, 1), ] { - meta_field(app, ui, label, key, value, lines); + meta_field(app, ui, label, key, value, lines, mixed.contains(&key)); } // file: name (rename), path (reveal), capture time (edit) let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); @@ -740,28 +748,33 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("State / Province", "state", &m.state), ("Country", "country", &m.country), ] { - meta_field(app, ui, label, key, value, 1); + meta_field(app, ui, label, key, value, 1, mixed.contains(&key)); } // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it - let gps = m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default(); + let gps_mixed = mixed.contains(&"gps"); + 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 text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed)); + 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"); register(ui.ctx(), "field:gps", r.rect); + edit.typed |= edit.text != edit.start; if r.has_focus() { - ui.data_mut(|d| d.insert_temp(gid, text.clone())); + ui.data_mut(|d| d.insert_temp(gid, edit.clone())); } else { - ui.data_mut(|d| d.remove::(gid)); + ui.data_mut(|d| d.remove::(gid)); } + let text = edit.text.trim(); if r.lost_focus() - && text.trim() != gps - && let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()})) + && (text != edit.start || edit.clears()) + && let Err(e) = app.run("photo.setMeta", json!({"gps": text})) { app.toast(ui.ctx(), e); } - if let Some((la, lo)) = m.gps { + // (one photo's place: not shown while the selected photos' places differ) + if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) { let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); ui.horizontal(|ui| { ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); @@ -896,15 +909,117 @@ fn human_size(bytes: u64) -> String { } } -/// A labelled metadata text field: the typed text lives in egui memory while focused and is -/// saved (photo.setMeta `key`) when the field loses focus. -/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). -fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { +/// What a metadata field shows while the selected photos' values differ (Selected Photos). +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. +#[derive(Clone)] +struct FieldEdit { + text: String, + start: String, + /// The field said Mixed when the edit started. + mixed: bool, + /// Something was typed or deleted since. + typed: bool, +} + +impl FieldEdit { + fn new(value: &str, mixed: bool) -> Self { + FieldEdit { text: value.to_string(), start: value.to_string(), mixed, typed: false } + } + + /// A Mixed field that was typed in and left empty: it clears the field on every selected photo. + fn clears(&self) -> bool { + self.mixed && self.typed + } +} + +/// The Info panel's text fields, by their `photo.setMeta` key. +const META_TEXT_KEYS: [&str; 12] = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "city", + "state", + "country", +]; + +/// The value of one of [`META_TEXT_KEYS`]. +fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> { + Some(match key { + "title" => &m.title, + "caption" => &m.caption, + "altText" => &m.alt_text, + "extendedDescription" => &m.extended_description, + "copyright" => &m.copyright, + "usageTerms" => &m.usage_terms, + "copyrightUrl" => &m.copyright_url, + "creator" => &m.creator, + "location" => &m.location, + "city" => &m.city, + "state" => &m.state, + "country" => &m.country, + _ => return None, + }) +} + +/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of +/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws. +pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> { + let mut mixed = Vec::new(); + for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) { + for key in META_TEXT_KEYS { + if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) { + mixed.push(key); + } + } + if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status { + mixed.push("copyrightStatus"); + } + if !mixed.contains(&"gps") && m.gps != shown.gps { + mixed.push("gps"); + } + if mixed.len() == META_TEXT_KEYS.len() + 2 { + break; + } + } + mixed +} + +/// Target Photo / Selected Photos: whose metadata the fields below show while several photos +/// are selected (`view.infoSelectedPhotos`). +fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + let on = app.ui.settings.info_selected_photos; + for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] { + if text_button(ui, id, label, on == selected).clicked() && on != selected { + let _ = app.run("view.infoSelectedPhotos", json!({"on": selected})); + } + } + }); + ui.add_space(6.0); +} + +/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the +/// selected photos' statuses differ, and any status picked is given to all of them. +fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { + let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() }); + let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| { for st in lightcraft_catalog::CopyrightStatus::ALL { - if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { + let on = !mixed && st == current; + if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on { let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); } } @@ -913,27 +1028,34 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr ui.add_space(6.0); } -fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { +/// A labeled metadata text field: the typed text lives in egui memory while focused and is +/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values +/// differ, so the field starts empty and says so, and is saved only when it was typed in (left +/// empty, it clears the field on every selected photo). +fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool) { let t = Tokens::get(ui.ctx()); 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 mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string()); + 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 widget = format!("field:{key}"); let field = if lines > 1 { - crate::text_field::TextField::multiline(&widget, &mut text).rows(lines) + crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) } else { - crate::text_field::TextField::singleline(&widget, &mut text) + crate::text_field::TextField::singleline(&widget, &mut edit.text) }; + let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field }; let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) if r.editing { - ui.data_mut(|d| d.insert_temp(id, text.clone())); + ui.data_mut(|d| d.insert_temp(id, edit.clone())); } else { - ui.data_mut(|d| d.remove::(id)); + ui.data_mut(|d| d.remove::(id)); } // Return (one line) or leaving saves; Esc gives the edit up - if r.committed() && text != value { - let _ = app.run("photo.setMeta", json!({key: text})); + if r.committed() && (edit.text != edit.start || edit.clears()) { + let _ = app.run("photo.setMeta", json!({key: edit.text})); } ui.add_space(6.0); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 53d5b8718..353514da8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -458,6 +458,10 @@ pub struct AppSettings { pub photocraft_file: EditFile, /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). pub memory_mb: u32, + /// The Info panel's metadata fields with several photos selected: false shows the active + /// photo's values (Target Photo), true shows the values the selected photos share and marks + /// each field where they differ (Selected Photos) (`view.infoSelectedPhotos`). + pub info_selected_photos: bool, /// Filmstrip: file names above the thumbnails. pub film_names: bool, /// Filmstrip: rating / flag / edited badges on the thumbnails. @@ -526,6 +530,7 @@ impl Default for AppSettings { photocraft_path: String::new(), photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, + info_selected_photos: false, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..4599cc7b6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -135,6 +135,12 @@ x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls `focus.pump {pace}` every frame to drive it. +With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or +`on: null`, toggles; any other value that is not true or false is refused; replies `{on}`; +`settings.infoSelectedPhotos`): off (Target Photo, the default) shows the active photo's values, on (Selected Photos) +shows the values the selected photos share, leaves each text field and GPS where they differ empty with a Mixed hint, +and shows Mixed as the Copyright Status when the statuses differ. + The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. diff --git a/docs/gaps.md b/docs/gaps.md index a8cdb5e5c..5f6df76de 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -44,7 +44,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | | 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | -| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) | +| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target. The Info panel's Show Metadata For choice (Target Photo / Selected Photos) shows the values the selected photos share and marks each field where they differ as Mixed; the All Metadata dialog still shows one photo's values with no "mixed" indication | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 1–3 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | | 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | | 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7fa952efb..9dee5de4d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -164,7 +164,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Target Photo (the active photo's values, the default) or Selected Photos (the values they share; a text field or GPS where they differ is empty and says Mixed, Copyright Status shows Mixed, and only what is typed or picked there is saved, to every selected photo). No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | From e25cc3ca0b7548577467a99d3b2b563b7de57ab1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:43:44 -0700 Subject: [PATCH 2/4] LR-META-INFO: Selected Photos reads the selection when the active photo is outside it The active photo can be one that is not selected (library.select with an `active` outside `ids`). With Selected Photos on, the Info fields then showed that photo's values and marked fields Mixed against it. The fields now show the active photo's values only when it is selected, and else the first selected photo's, and compare the selected photos with those. --- crates/ui-egui/src/headless.rs | 31 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 20 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e06903fb0..fe4e255d4 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3047,6 +3047,37 @@ mod tests { assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]); } + /// Selected Photos with the active photo outside the selection: the fields show what the + /// selected photos share, not the active photo's values. + #[test] + fn info_selected_photos_ignore_an_active_photo_outside_the_selection() { + let mut h = demo([1300.0, 1000.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(3).map(|p| p.0).collect(); + let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": true})); + 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": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into())); + } + /// 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 4b88aa20f..e5a454559 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -674,12 +674,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); divider(ui); - let m = p.meta.clone(); // with several photos selected: Target Photo shows this photo's values, Selected Photos what // the selection shares (a field where they differ is empty and says Mixed) let selected = app.session.selected(); let several = selected.len() > 1; - let mixed = if several && app.ui.settings.info_selected_photos { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; + let for_selected = several && app.ui.settings.info_selected_photos; + let m = shown_meta(&app.session.catalog, &p, &selected, for_selected); + let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; padded(ui, |ui| { if several { metadata_for(app, ui); @@ -971,6 +972,21 @@ fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> }) } +/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected` +/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first +/// selected photo's. +pub(crate) fn shown_meta( + catalog: &lightcraft_catalog::Catalog, + p: &lightcraft_catalog::Photo, + selected: &[PhotoId], + for_selected: bool, +) -> lightcraft_catalog::Meta { + if !for_selected || selected.contains(&p.id) { + return p.meta.clone(); + } + selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone()) +} + /// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of /// the photos `ids` has a value other than `shown`'s, the metadata the panel draws. pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> { From 7a4e842dd58a5bc6e1dcba36488dbd994c050ea7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:51:15 -0700 Subject: [PATCH 3/4] 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); } From 1b0a9dae1b33dc40a0d7491a28274bc3680c698a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:49:45 -0700 Subject: [PATCH 4/4] LR-META-INFO: the Info panel's GPS field is a TextField, so Esc gives an edit up The GPS box was an egui TextEdit: Esc left the field with the typed text kept, and leaving the field saved it. It is now the app's TextField, as the other Info fields are: Esc puts the text back and saves nothing, and the field gets the Cut / Copy / Paste / Select All menu. --- crates/ui-egui/src/headless.rs | 11 +++++++++++ crates/ui-egui/src/panels/right.rs | 9 ++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b7343b169..296a1247b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3117,6 +3117,17 @@ mod tests { h.settle(SETTLE); let gps: Vec = (0..4).map(|i| meta(&h, i).gps.is_some()).collect(); assert_eq!(gps, [true, true, false, false]); + // Esc gives a GPS edit up + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + let before = meta(&h, 0).gps; + ask(&mut h, "ui.clickWidget", json!({"id": "field:gps"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "5"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before)); } /// Local: a folder's photos show without joining the library; the breadcrumb, Include diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6da043831..1afad9bc3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -758,17 +758,16 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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, &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"); - register(ui.ctx(), "field:gps", r.rect); + let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui); edit.typed |= edit.text != edit.start; - if r.has_focus() { + if r.editing { ui.data_mut(|d| d.insert_temp(gid, edit.clone())); } else { ui.data_mut(|d| d.remove::(gid)); } + // Return or leaving saves; Esc gives the edit up let text = edit.text.trim(); - if r.lost_focus() + if r.committed() && (text != edit.start || edit.clears()) && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) {