mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
Info panel Target Photo edits, Add Keywords cursor request and Fixed keys follow-ups (#2889)
* LR-PREF-SHORTCUTS: the shortcuts panel's Fixed keys ignore keys saved for unknown commands * KEYC-METADATA: an Add Keywords request the Keywords panel can't take in time expires * LR-META-INFO: Target Photo saves an Info edit to the photo it shows
This commit is contained in:
@@ -3057,6 +3057,37 @@ mod tests {
|
||||
assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into()));
|
||||
}
|
||||
|
||||
/// Target Photo edits the photo it shows: with several photos selected, a field's edit is
|
||||
/// saved to the active photo alone.
|
||||
#[test]
|
||||
fn info_target_photo_edits_only_the_photo_it_shows() {
|
||||
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(2).map(|p| p.0).collect();
|
||||
let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone();
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"}));
|
||||
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"}));
|
||||
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
|
||||
exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]}));
|
||||
exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}));
|
||||
h.settle(SETTLE);
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"}));
|
||||
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!((caption(&h, 0), caption(&h, 1)), ("Dawn!".into(), "Dusk".into()));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -1283,7 +1283,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
panel(app, &ctx, RightPanel::Keywords, "Keywords");
|
||||
}
|
||||
app.ui.folded_sections.retain(|s| s != "keywording");
|
||||
app.ui.focus_keywords = true;
|
||||
app.ui.focus_keywords = Some(ctx.cumulative_pass_nr());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"panel.versions" => {
|
||||
|
||||
@@ -172,7 +172,8 @@ fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, nee
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text));
|
||||
let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect();
|
||||
// keys saved for commands this build knows: an entry for an unknown command takes no key
|
||||
let taken: Vec<_> = shortcuts::saved_keys(&app.ui.settings.keymap).map(|(_, k)| k).collect();
|
||||
egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (sc, label) in fixed {
|
||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
// the Keyword List is the library's, not a photo's: it is there without a selection
|
||||
if app.ui.right == RightPanel::Keywords {
|
||||
// (without a selection there is no Add keyword box for Add Keywords to put the cursor in)
|
||||
app.ui.focus_keywords = false;
|
||||
app.ui.focus_keywords = None;
|
||||
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
if fold_header(app, ui, "keywording", "Keywords") {
|
||||
@@ -683,6 +683,8 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
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() };
|
||||
// the photos an edit goes to: Target Photo edits the photo it shows
|
||||
let targets = if several && !for_selected { vec![id] } else { selected.clone() };
|
||||
padded(ui, |ui| {
|
||||
if several {
|
||||
metadata_for(app, ui);
|
||||
@@ -694,15 +696,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, mixed.contains(&key));
|
||||
meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets);
|
||||
}
|
||||
copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"));
|
||||
copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"), &targets);
|
||||
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, mixed.contains(&key));
|
||||
meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets);
|
||||
}
|
||||
// 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));
|
||||
@@ -751,14 +753,14 @@ 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, mixed.contains(&key));
|
||||
meta_field(app, ui, label, key, value, 1, mixed.contains(&key), &targets);
|
||||
}
|
||||
// GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it
|
||||
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 edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &selected));
|
||||
let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets));
|
||||
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;
|
||||
@@ -916,13 +918,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. It saves to the photos selected when the
|
||||
/// edit started, also when the selection changed since.
|
||||
/// when what the panel shows changed during the edit. It saves to the photos it edited when the
|
||||
/// edit started (the selected photos, or Target Photo's photo alone), also when the selection
|
||||
/// changed since.
|
||||
#[derive(Clone)]
|
||||
struct FieldEdit {
|
||||
text: String,
|
||||
start: String,
|
||||
/// The selected photos when the edit started (`photo.setMeta` `ids`).
|
||||
/// The photos the field edited when the edit started (`photo.setMeta` `ids`).
|
||||
ids: Vec<u64>,
|
||||
/// The field said Mixed when the edit started.
|
||||
mixed: bool,
|
||||
@@ -931,8 +934,8 @@ struct FieldEdit {
|
||||
}
|
||||
|
||||
impl FieldEdit {
|
||||
fn new(value: &str, mixed: bool, selected: &[PhotoId]) -> Self {
|
||||
let ids = selected.iter().map(|id| id.0).collect();
|
||||
fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self {
|
||||
let ids = targets.iter().map(|id| id.0).collect();
|
||||
FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false }
|
||||
}
|
||||
|
||||
@@ -1032,8 +1035,9 @@ fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// selected photos' statuses differ, and any status picked is given to all of them. A status
|
||||
/// picked goes to the photos `targets`.
|
||||
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool, targets: &[PhotoId]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim));
|
||||
let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() });
|
||||
@@ -1041,7 +1045,8 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
|
||||
for st in lightcraft_catalog::CopyrightStatus::ALL {
|
||||
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()}));
|
||||
let ids: Vec<u64> = targets.iter().map(|id| id.0).collect();
|
||||
let _ = app.run("photo.setMeta", json!({"ids": ids, "copyrightStatus": st.id()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1052,13 +1057,14 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
|
||||
/// 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) {
|
||||
/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool, targets: &[PhotoId]) {
|
||||
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 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 mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets));
|
||||
let widget = format!("field:{key}");
|
||||
let field = if lines > 1 {
|
||||
crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines)
|
||||
@@ -1158,7 +1164,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let bid = egui::Id::new("kw-begin");
|
||||
let begin = ui.data_mut(|d| d.remove_temp::<u64>(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass));
|
||||
// Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight
|
||||
let asked = std::mem::take(&mut app.ui.focus_keywords);
|
||||
let asked = app.ui.focus_keywords.take().is_some_and(|at| pass <= at.saturating_add(crate::state::FOCUS_KEYWORDS_PASSES));
|
||||
let r = crate::text_field::TextField::singleline("field:keyword", &mut text)
|
||||
.hint(crate::i18n::tr("Add keyword"))
|
||||
.width(f32::INFINITY)
|
||||
|
||||
@@ -74,7 +74,7 @@ fn assigned_elsewhere(keymap: &Keymap, id: &str, sc: &str) -> bool {
|
||||
|
||||
/// The keys the keymap gives to known commands, with each command's id. Entries for commands this
|
||||
/// build doesn't know and entries that don't parse are ignored.
|
||||
fn saved_keys(keymap: &Keymap) -> impl Iterator<Item = (&str, (Modifiers, Key))> {
|
||||
pub(crate) fn saved_keys(keymap: &Keymap) -> impl Iterator<Item = (&str, (Modifiers, Key))> {
|
||||
keymap.iter().filter(|(id, _)| find_bindable(id).is_some()).filter_map(|(id, sc)| Some((id.as_str(), parse(sc)?)))
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,10 @@ pub struct LoupeInfo {
|
||||
pub briefly: InfoOverlay,
|
||||
}
|
||||
|
||||
/// Passes after Add Keywords within which the Add keyword box takes the cursor (the panel may
|
||||
/// need a pass to open first).
|
||||
pub const FOCUS_KEYWORDS_PASSES: u64 = 2;
|
||||
|
||||
/// How long Show Briefly When Photo Changes shows the info overlay (seconds).
|
||||
pub const BRIEF_INFO_SECS: f64 = 3.0;
|
||||
|
||||
@@ -928,9 +932,11 @@ pub struct UiState {
|
||||
/// Focus the search field on the next frame (Edit → Find…).
|
||||
#[serde(skip)]
|
||||
pub focus_search: bool,
|
||||
/// Put the cursor in the Keywords panel's Add keyword box when it is next drawn (Add Keywords).
|
||||
/// Put the cursor in the Keywords panel's Add keyword box (Add Keywords): the egui pass it was
|
||||
/// asked on. A box drawn more than [`FOCUS_KEYWORDS_PASSES`] passes later doesn't take it, so
|
||||
/// a request made while the panel can't show expires.
|
||||
#[serde(skip)]
|
||||
pub focus_keywords: bool,
|
||||
pub focus_keywords: Option<u64>,
|
||||
/// A mask being renamed in the Masks list: its id and the edited name.
|
||||
#[serde(skip)]
|
||||
pub renaming_mask: Option<(u32, String)>,
|
||||
@@ -1542,7 +1548,7 @@ impl Default for UiState {
|
||||
known_cards: None,
|
||||
search: String::new(),
|
||||
focus_search: false,
|
||||
focus_keywords: false,
|
||||
focus_keywords: None,
|
||||
renaming_mask: None,
|
||||
describe: None,
|
||||
detail_due: None,
|
||||
|
||||
@@ -144,6 +144,19 @@ fn cmd_k_without_a_photo_leaves_the_cursor_alone() {
|
||||
assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box waits for a click");
|
||||
}
|
||||
|
||||
/// An Add Keywords request the box wasn't drawn for in time (the panel couldn't show) expires: a
|
||||
/// later draw of the box leaves the cursor alone.
|
||||
#[test]
|
||||
fn a_stale_add_keywords_request_leaves_the_cursor_alone() {
|
||||
let (mut h, _) = two_selected();
|
||||
ask(&mut h, "ui.set", json!({"right": "keywords"}));
|
||||
assert!(has(&h, "field:keyword"));
|
||||
h.app.ui.focus_keywords = Some(h.view.ctx.cumulative_pass_nr().saturating_sub(crate::state::FOCUS_KEYWORDS_PASSES + 1));
|
||||
h.step();
|
||||
assert_eq!(h.app.ui.focus_keywords, None, "the request is used up");
|
||||
assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box waits for a click");
|
||||
}
|
||||
|
||||
/// The keyword set's buttons say whether the selection has each keyword, as their ⌥1–⌥9 toggle it
|
||||
/// on the selection: on when every selected photo has it, partly on when only some do.
|
||||
#[test]
|
||||
|
||||
@@ -137,7 +137,7 @@ file, on a background job (activity kind `focus`); `wait: true` returns the run'
|
||||
|
||||
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) shows the active photo's values, on (Selected Photos, the default)
|
||||
`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values and saves edits to it alone, on (Selected Photos, the default)
|
||||
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.
|
||||
|
||||
|
||||
@@ -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`, `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 Selected Photos (the default: 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) or Target Photo (the active photo's values). 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 Selected Photos (the default: 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) or Target Photo (the active photo's values; an edit goes to that photo alone). 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 |
|
||||
|
||||
Reference in New Issue
Block a user