diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 53e1019e3..200d80be0 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -105,6 +105,27 @@ pub struct DateRun { pub count: usize, } +/// A capture time for display: `2022-03-30T22:11:11` → "March 30, 2022 at 10:11:11 PM" (the +/// date alone when there is no time; the input unchanged when it doesn't parse). +pub fn display_time(iso: &str) -> String { + let num = |r: std::ops::Range| iso.get(r).and_then(|v| v.parse::().ok()); + let (Some(y), Some(mo), Some(d)) = (num(0..4), num(5..7), num(8..10)) else { return iso.to_string() }; + let Some(month) = MONTHS.get((mo as usize).wrapping_sub(1)) else { return iso.to_string() }; + let date = format!("{month} {d}, {y}"); + match (num(11..13), num(14..16), num(17..19)) { + (Some(h), Some(mi), s) => { + let (h12, ampm) = match h { + 0 => (12, "AM"), + 1..=11 => (h, "AM"), + 12 => (12, "PM"), + _ => (h - 12, "PM"), + }; + format!("{date} at {h12}:{mi:02}:{:02} {ampm}", s.unwrap_or(0)) + } + _ => date, + } +} + /// Header text for a group key. pub fn group_label(key: &str) -> String { let month = |k: &str| k.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]); diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index dd4a78f81..e67f2cc5f 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -103,10 +103,17 @@ pub struct Meta { pub aperture: Option, pub shutter: String, pub iso: Option, + /// Place (sublocation, e.g. a landmark). pub location: String, + pub city: String, + pub state: String, + pub country: String, pub gps: Option<(f64, f64)>, pub title: String, pub caption: String, + /// Accessibility text and long description. + pub alt_text: String, + pub extended_description: String, pub copyright: String, pub creator: String, pub keywords: Vec, diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 8f1ee5b64..234f637e6 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -195,3 +195,13 @@ fn smart_albums_update_live() { assert_eq!(back.album_photos(id), vec![b, z]); assert!(Filter { rating: 3, keyword: Some("sea".into()), ..Default::default() }.describe().contains("rating ≥ 3, keyword sea")); } + +#[test] +fn capture_times_display_like_the_info_panel() { + use crate::dates::display_time; + assert_eq!(display_time("2022-03-30T10:11:11"), "March 30, 2022 at 10:11:11 AM"); + assert_eq!(display_time("2022-03-30T22:05:01.5-04:00"), "March 30, 2022 at 10:05:01 PM"); + assert_eq!(display_time("2022-01-02T00:00:00"), "January 2, 2022 at 12:00:00 AM"); + assert_eq!(display_time("2022-07-04"), "July 4, 2022"); + assert_eq!(display_time("someday"), "someday"); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d64a43fef..33cd47389 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -246,7 +246,9 @@ pub fn specs() -> Vec { cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright, creator, location and keywords of the active photo", has_active, |s, _| { let id = s.active().ok_or_else(|| bad("photo.copyMetadata", "no active photo"))?; let m = &s.catalog.photo(id).ok_or_else(|| bad("photo.copyMetadata", "no photo"))?.meta; - let v = json!({"title": m.title, "caption": m.caption, "copyright": m.copyright, "creator": m.creator, "location": m.location, "keywords": m.keywords}); + let v = json!({"title": m.title, "caption": m.caption, "altText": m.alt_text, "extendedDescription": m.extended_description, + "copyright": m.copyright, "creator": m.creator, "location": m.location, "city": m.city, "state": m.state, "country": m.country, + "keywords": m.keywords}); s.meta_clipboard = Some(v.clone()); Ok(v) }), @@ -359,7 +361,7 @@ pub fn specs() -> Vec { "Edit Info", [], None, - "{ids?, title?, caption?, copyright?, creator?, location?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", + "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", has_selection, |s, p| { let strs = @@ -375,6 +377,11 @@ pub fn specs() -> Vec { ("copyright", &mut m.copyright), ("creator", &mut m.creator), ("location", &mut m.location), + ("city", &mut m.city), + ("state", &mut m.state), + ("country", &mut m.country), + ("altText", &mut m.alt_text), + ("extendedDescription", &mut m.extended_description), ] { if let Some(v) = str_param(p, k) { *field = v.to_string(); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index c673ee161..949a71c3e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -778,6 +778,14 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> } out.title = text(&m.title); out.caption = text(&m.caption); + out.alt_text = text(&m.alt_text); + out.extended_description = text(&m.extended_description); + if !o.remove_location { + out.sublocation = text(&m.location); + out.city = text(&m.city); + out.state = text(&m.state); + out.country = text(&m.country); + } out.keywords = m.keywords.clone(); out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); out.rating = (photo.rating > 0).then_some(photo.rating as i8); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index a857f6cb1..3dbe51e5d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -23,7 +23,12 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { aperture: m.f_number.map(|f| f as f32), shutter, iso: m.iso, - location: String::new(), + location: m.sublocation.clone().unwrap_or_default(), + city: m.city.clone().unwrap_or_default(), + state: m.state.clone().unwrap_or_default(), + country: m.country.clone().unwrap_or_default(), + alt_text: m.alt_text.clone().unwrap_or_default(), + extended_description: m.extended_description.clone().unwrap_or_default(), gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)), title: m.title.clone().unwrap_or_default(), caption: m.caption.clone().unwrap_or_default(), diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index e62d2b091..0a54b8e65 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -72,6 +72,11 @@ pub struct SidecarData { pub copyright: Option, pub creator: Option, pub location: Option, + pub city: Option, + pub state: Option, + pub country: Option, + pub alt_text: Option, + pub extended_description: Option, pub keywords: Option>, pub develop: Option, } @@ -94,7 +99,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result bool { (&sc.copyright, &mut m.copyright), (&sc.creator, &mut m.creator), (&sc.location, &mut m.location), + (&sc.city, &mut m.city), + (&sc.state, &mut m.state), + (&sc.country, &mut m.country), + (&sc.alt_text, &mut m.alt_text), + (&sc.extended_description, &mut m.extended_description), ] { if let Some(v) = src { *dst = v.clone(); @@ -171,6 +186,12 @@ pub fn sidecar_packet(p: &Photo) -> String { software: Some("LightCraft".into()), title: nz(&p.meta.title), caption: nz(&p.meta.caption), + alt_text: nz(&p.meta.alt_text), + extended_description: nz(&p.meta.extended_description), + sublocation: nz(&p.meta.location), + city: nz(&p.meta.city), + state: nz(&p.meta.state), + country: nz(&p.meta.country), copyright: nz(&p.meta.copyright), artist: nz(&p.meta.creator), keywords: p.meta.keywords.clone(), diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index cfc678c75..fe283d955 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -83,6 +83,16 @@ pub struct Metadata { pub copyright: Option, pub title: Option, pub caption: Option, + /// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`). + pub alt_text: Option, + /// Long accessibility description (`Iptc4xmpCore:ExtDescrAccessibility`). + pub extended_description: Option, + /// Place within the city (`Iptc4xmpCore:Location`). + pub sublocation: Option, + /// `photoshop:City`, `photoshop:State`, `photoshop:Country`. + pub city: Option, + pub state: Option, + pub country: Option, pub keywords: Vec, /// Hierarchical keywords, `|`-separated paths (`lr:hierarchicalSubject` convention). pub hierarchical_keywords: Vec, @@ -132,6 +142,12 @@ impl Metadata { copyright, title, caption, + alt_text, + extended_description, + sublocation, + city, + state, + country, rating, label ); diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 06b6569a8..2643532f6 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -348,6 +348,12 @@ pub fn parse_xmp(s: &str) -> Result { height: num("tiff:ImageLength").or_else(|| num("exif:PixelYDimension")).map(|v| v as u32), title: first("dc:title"), caption: first("dc:description"), + alt_text: first("Iptc4xmpCore:AltTextAccessibility"), + extended_description: first("Iptc4xmpCore:ExtDescrAccessibility"), + sublocation: first("Iptc4xmpCore:Location"), + city: first("photoshop:City"), + state: first("photoshop:State"), + country: first("photoshop:Country"), copyright: first("dc:rights"), rating: num("xmp:Rating").map(|v| v.round().clamp(-1.0, 5.0) as i8), label: first("xmp:Label"), @@ -444,6 +450,10 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { push("exifEX:LensMake", meta.lens_make.clone()); push("exifEX:LensModel", meta.lens_model.clone()); push("exifEX:LensSerialNumber", meta.lens_serial_number.clone()); + push("Iptc4xmpCore:Location", meta.sublocation.clone()); + push("photoshop:City", meta.city.clone()); + push("photoshop:State", meta.state.clone()); + push("photoshop:Country", meta.country.clone()); if let Some(g) = meta.gps { push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S'))); push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W'))); @@ -488,6 +498,8 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { let one = |v: &Option| v.iter().cloned().collect::>(); array(&mut x, "dc:title", "Alt", &one(&meta.title), true); array(&mut x, "dc:description", "Alt", &one(&meta.caption), true); + array(&mut x, "Iptc4xmpCore:AltTextAccessibility", "Alt", &one(&meta.alt_text), true); + array(&mut x, "Iptc4xmpCore:ExtDescrAccessibility", "Alt", &one(&meta.extended_description), true); array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true); array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false); array(&mut x, "dc:subject", "Bag", &meta.keywords, false); @@ -545,6 +557,12 @@ mod tests { copyright: Some("© 2022".into()), title: Some("Tïtle".into()), caption: Some("Line one\nline two".into()), + alt_text: Some("A tree by the sea".into()), + extended_description: Some("Long ".into()), + sublocation: Some("Champ de Mars".into()), + city: Some("Paris".into()), + state: Some("Île-de-France".into()), + country: Some("France".into()), keywords: vec!["tree".into(), "sky & sea".into()], hierarchical_keywords: vec!["Places|France|Paris".into()], rating: Some(-1), diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 5748b072b..ddd50bcfa 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -644,6 +644,29 @@ mod tests { assert!(!h.app.ui.visualize_spots); } + /// Info panel: typing into a field survives frames and is saved when it loses focus; the new + /// accessibility and place fields reach the photo. + #[test] + fn info_fields_keep_typing_and_save() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + h.settle(SETTLE); + for (key, text) in [("altText", "A lake at dawn"), ("city", "Zermatt")] { + let r = h.request("ui.clickWidget", json!({"id": format!("field:{key}")}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("ui.key", json!({"key": "A", "cmd": true}), t); + h.request("ui.text", json!({"text": text}), t); + h.step(); + h.step(); + // still being edited: not saved yet, not lost either + h.request("ui.key", json!({"key": "Tab"}), t); + h.settle(Duration::from_secs(5)); + } + let m = &h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().meta; + assert_eq!((m.alt_text.as_str(), m.city.as_str()), ("A lake at dawn", "Zermatt")); + } + /// ⌘Q (File → Quit LightCraft) closes the window. #[test] fn cmd_q_quits() { @@ -727,7 +750,7 @@ mod tests { h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); - let r = h.request("ui.clickWidget", json!({"id": "button:editCaptureTime"}), t); + let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t); assert_eq!(r["ok"], true, "{r}"); h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 258dbaf0d..58c90c241 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -51,6 +51,8 @@ pub enum Icon { SmartAlbum, Stack, Folder, + /// Edit (a pencil). + Pencil, Photos, Clock, Trash, @@ -336,6 +338,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.closed(&[(5.5, 8.5), (12.5, 8.5), (10.0, 11.5), (10.0, 14.5), (8.0, 13.5), (8.0, 11.5)]); } Folder => pen.closed(&[(2.5, 5.0), (8.0, 5.0), (9.5, 7.0), (17.5, 7.0), (17.5, 15.5), (2.5, 15.5)]), + Pencil => { + pen.closed(&[(4.0, 16.0), (4.5, 12.5), (13.5, 3.5), (16.5, 6.5), (7.5, 15.5)]); + pen.line(&[(11.5, 5.5), (14.5, 8.5)]); + } Photos => { pen.rect(2.5, 4.0, 17.5, 16.0, 1.5); pen.line(&[(2.5, 13.5), (7.0, 9.5), (11.0, 13.0), (13.5, 11.0), (17.5, 14.5)]); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index c40ec36d9..a40b767ad 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -371,8 +371,8 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { header(ui, "Info"); let t = Tokens::get(ui.ctx()); padded(ui, |ui| { - ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text)); - ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim)); + camera_card(ui, &p); + ui.add_space(6.0); if let Some(name) = &p.copy_name { let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into()); ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label)); @@ -404,38 +404,141 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); divider(ui); - let mut title = p.meta.title.clone(); - let mut caption = p.meta.caption.clone(); - let mut copyright = p.meta.copyright.clone(); + let m = p.meta.clone(); padded(ui, |ui| { - for (label, val, key) in [("Title", &mut title, "title"), ("Caption", &mut caption, "caption"), ("Copyright", &mut copyright, "copyright")] { - ui.label(egui::RichText::new(label).color(t.text_dim)); - let r = ui.add(egui::TextEdit::singleline(val).desired_width(f32::INFINITY)); - register(ui.ctx(), format!("field:{key}"), r.rect); - if r.lost_focus() { - let _ = app.run("photo.setMeta", json!({key: val.clone()})); + for (label, key, value, lines) in [ + ("Title", "title", &m.title, 1), + ("Caption", "caption", &m.caption, 2), + ("Alt Text", "altText", &m.alt_text, 2), + ("Extended Description", "extendedDescription", &m.extended_description, 3), + ("Copyright", "copyright", &m.copyright, 1), + ("Creator", "creator", &m.creator, 1), + ] { + meta_field(app, ui, label, key, value, lines); + } + // file: name (rename), path (reveal), capture time (edit) + let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim)); + small(ui, "File Name"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() { + let _ = app.run("dialog.rename", json!({})); } - ui.add_space(6.0); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(&p.file_name).color(t.text)).truncate()); + }); + }); + ui.add_space(4.0); + small(ui, "File Path"); + ui.horizontal(|ui| { + let path = match &p.source { + lightcraft_catalog::Source::File { path } => path.clone(), + lightcraft_catalog::Source::Demo { .. } => "Generated demo photo".into(), + }; + ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate()); + if crate::menus::ui_enabled(app, "app.showInFinder") { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, "Show in Finder").clicked() { + let _ = app.run("app.showInFinder", json!({})); + } + }); + } + }); + ui.add_space(4.0); + small(ui, "Captured"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { + let _ = app.run("dialog.captureTime", json!({})); + } + let cap = p.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "—".into()); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); + }); + }); + ui.add_space(6.0); + for (label, key, value) in [ + ("Location", "location", &m.location), + ("City", "city", &m.city), + ("State / Province", "state", &m.state), + ("Country", "country", &m.country), + ] { + meta_field(app, ui, label, key, value, 1); } + small(ui, "GPS"); + let gps = m.gps.map(|(la, lo)| { + format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }) + }); + ui.label(egui::RichText::new(gps.unwrap_or_else(|| "—".into())).color(t.text_label)); }); - divider(ui); - header(ui, "Camera"); +} + +/// The camera card at the top of Info: camera, lens, size · file size and format, then the +/// capture settings. +fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { + let t = Tokens::get(ui.ctx()); let m = &p.meta; - let cap = p.captured.as_deref().map(|c| c.replace('T', " ")).unwrap_or_else(|| "—".into()); - label_row(ui, "Captured", &cap); - padded(ui, |ui| { - if text_button(ui, "editCaptureTime", "Edit Capture Time…", false).clicked() { - let _ = app.run("dialog.captureTime", json!({})); + egui::Frame::NONE.fill(t.canvas).corner_radius(6.0).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + let dim = |s: &str| egui::RichText::new(s.to_string()).size(12.0).color(t.text_label); + if !m.camera.is_empty() { + ui.label(dim(&m.camera)); + } + if !m.lens.is_empty() { + ui.add(egui::Label::new(dim(&m.lens)).truncate()); + } + ui.horizontal(|ui| { + ui.label(dim(&format!("{} × {} · {}", p.width, p.height, human_size(p.file_size)))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::Frame::NONE.fill(t.chrome).corner_radius(3.0).inner_margin(egui::Margin::symmetric(5, 1)).show(ui, |ui| { + ui.label(egui::RichText::new(p.format.to_uppercase()).size(10.5).color(t.text_label)); + }); + }); + }); + let rows = [ + ("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))), + ("Shutter speed", (!m.shutter.is_empty()).then(|| format!("{} sec", m.shutter))), + ("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))), + ("ISO", m.iso.map(|i| i.to_string())), + ]; + if rows.iter().any(|r| r.1.is_some()) { + ui.add_space(6.0); + egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| { + for (k, v) in rows { + ui.label(dim(k)); + ui.label(dim(&v.unwrap_or_else(|| "—".into()))); + ui.end_row(); + } + }); } }); - label_row(ui, "Camera", &m.camera); - label_row(ui, "Lens", &m.lens); - label_row(ui, "Focal", &m.focal_mm.map(|f| format!("{f:.0} mm")).unwrap_or_default()); - label_row(ui, "Aperture", &m.aperture.map(|f| format!("f/{f:.1}")).unwrap_or_default()); - label_row(ui, "Shutter", &m.shutter); - label_row(ui, "ISO", &m.iso.map(|f| f.to_string()).unwrap_or_default()); - label_row(ui, "Location", &m.location); - label_row(ui, "File size", &format!("{:.1} MB", p.file_size as f64 / 1e6)); +} + +fn human_size(bytes: u64) -> String { + match bytes { + b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), + b if b >= 1 << 10 => format!("{} KB", b >> 10), + b => format!("{b} B"), + } +} + +/// 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. +fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(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 edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; + let r = ui.add(edit.desired_width(f32::INFINITY)); + register(ui.ctx(), format!("field:{key}"), r.rect); + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + if r.lost_focus() && text != value { + let _ = app.run("photo.setMeta", json!({key: text})); + } + ui.add_space(6.0); } fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { diff --git a/docs/parity.md b/docs/parity.md index b11f4cd06..869739c23 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -189,10 +189,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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` | no flash, creator field, map snippet or people | +| 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, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | | 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`) | text field + GPS read; no map, no geocoding | -| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, copyright, creator, location, keywords; `fields` picks a subset | +| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`) | place fields edited in Info, read/written as IPTC Core / Photoshop XMP; GPS read-only; no map, no geocoding | +| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright, creator, place fields, keywords; `fields` picks a subset | | LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | 🟡 | `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; panel shows a subset; no metadata presets | @@ -438,7 +438,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | `cmd:photo.setMeta` (`location`), `crates/ui-egui/src/panels/right.rs` | shown as text; not editable in the panel; no map | +| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`), `crates/ui-egui/src/panels/right.rs` | location, city, state/province, country editable; GPS shown; no map | | LR-MAP-MODULE | Map module [Classic] | P2 | ⬜ | | | ## V. Preferences (PREF)