mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
LR-META-INFO: Info panel in Lightroom's layout with accessibility and place fields
- Metadata gains alt text, extended description, sublocation, city, state/province, country: catalog, photo.setMeta, copy/paste metadata, XMP read/write (Iptc4xmpCore AltText / ExtDescrAccessibility / Location, photoshop City / State / Country), sidecars and export (place fields dropped with "remove location"). - Info panel: camera card (camera, lens, size · file size, format badge, focal length, shutter, aperture, ISO), then title, caption, alt text, extended description, copyright, creator, file name (rename), file path (reveal), captured (edit), place fields, GPS. - Text fields keep what is typed across frames and save on focus loss (they were rebound to the stored value every frame). New pencil icon (drawn in code); dates::display_time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
be762c726b
commit
096aefe698
@@ -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<usize>| iso.get(r).and_then(|v| v.parse::<u32>().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::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
|
||||
|
||||
@@ -103,10 +103,17 @@ pub struct Meta {
|
||||
pub aperture: Option<f32>,
|
||||
pub shutter: String,
|
||||
pub iso: Option<u32>,
|
||||
/// 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<String>,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -246,7 +246,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
("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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,7 +23,12 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
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(),
|
||||
|
||||
@@ -72,6 +72,11 @@ pub struct SidecarData {
|
||||
pub copyright: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub city: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub alt_text: Option<String>,
|
||||
pub extended_description: Option<String>,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
pub develop: Option<DevelopPatch>,
|
||||
}
|
||||
@@ -94,7 +99,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
caption: m.caption.clone(),
|
||||
copyright: m.copyright.clone(),
|
||||
creator: m.artist.clone(),
|
||||
location: lc("location"),
|
||||
location: lc("location").or_else(|| m.sublocation.clone()),
|
||||
city: m.city.clone(),
|
||||
state: m.state.clone(),
|
||||
country: m.country.clone(),
|
||||
alt_text: m.alt_text.clone(),
|
||||
extended_description: m.extended_description.clone(),
|
||||
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -139,6 +149,11 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> 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(),
|
||||
|
||||
@@ -83,6 +83,16 @@ pub struct Metadata {
|
||||
pub copyright: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub caption: Option<String>,
|
||||
/// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`).
|
||||
pub alt_text: Option<String>,
|
||||
/// Long accessibility description (`Iptc4xmpCore:ExtDescrAccessibility`).
|
||||
pub extended_description: Option<String>,
|
||||
/// Place within the city (`Iptc4xmpCore:Location`).
|
||||
pub sublocation: Option<String>,
|
||||
/// `photoshop:City`, `photoshop:State`, `photoshop:Country`.
|
||||
pub city: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
/// Hierarchical keywords, `|`-separated paths (`lr:hierarchicalSubject` convention).
|
||||
pub hierarchical_keywords: Vec<String>,
|
||||
@@ -132,6 +142,12 @@ impl Metadata {
|
||||
copyright,
|
||||
title,
|
||||
caption,
|
||||
alt_text,
|
||||
extended_description,
|
||||
sublocation,
|
||||
city,
|
||||
state,
|
||||
country,
|
||||
rating,
|
||||
label
|
||||
);
|
||||
|
||||
@@ -348,6 +348,12 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
|
||||
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<String>| v.iter().cloned().collect::<Vec<_>>();
|
||||
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 <description>".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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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::<String>(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) {
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user