Merge PR #2810: LR-META-INFO: the Info panel can show metadata for the selected photos

This commit is contained in:
aiongg
2026-10-25 02:25:16 +00:00
7 changed files with 427 additions and 34 deletions
+228
View File
@@ -2926,6 +2926,234 @@ 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<u64> = 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<u64> = 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<lightcraft_catalog::PhotoId> = 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"]);
}
/// 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<u64> = 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()));
}
/// 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<u64> = 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<bool> = (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
/// subfolders and Add to My Photos work from the grid header.
#[test]
+12
View File
@@ -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"),
@@ -1137,6 +1139,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins);
Ok(json!({"maskPins": app.ui.mask_pins}))
}
"view.infoSelectedPhotos" => {
// {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") {
+172 -31
View File
@@ -676,8 +676,17 @@ 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 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);
}
for (label, key, value, lines) in [
("Title", "title", &m.title, 1),
("Caption", "caption", &m.caption, 2),
@@ -685,15 +694,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));
@@ -742,28 +751,32 @@ 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));
crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS");
register(ui.ctx(), "field:gps", r.rect);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(gid, text.clone()));
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 = 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.editing {
ui.data_mut(|d| d.insert_temp(gid, edit.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(gid));
ui.data_mut(|d| d.remove::<FieldEdit>(gid));
}
if r.lost_focus()
&& text.trim() != gps
&& let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()}))
// Return or leaving saves; Esc gives the edit up
let text = edit.text.trim();
if r.committed()
&& (text != edit.start || edit.clears())
&& let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "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));
@@ -898,15 +911,136 @@ 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. 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<u64>,
/// 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, 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.
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 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> {
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()}));
}
}
@@ -915,27 +1049,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, &app.session.selected()));
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::<String>(id));
ui.data_mut(|d| d.remove::<FieldEdit>(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!({"ids": edit.ids, key: edit.text}));
}
ui.add_space(6.0);
}
+5
View File
@@ -477,6 +477,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.
@@ -548,6 +552,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,
+6
View File
@@ -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.
+3 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; 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 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; 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; 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), ⌘⌥⇧C / ⌘⌥⇧V (Copy / Paste Metadata), 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)).
@@ -85,6 +85,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged |
| 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 |
+1 -1
View File
@@ -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 |