mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 02:49:56 -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()));
|
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
|
/// 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.
|
/// where a selected photo differs from the photo the panel shows, and none for one photo.
|
||||||
#[test]
|
#[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");
|
panel(app, &ctx, RightPanel::Keywords, "Keywords");
|
||||||
}
|
}
|
||||||
app.ui.folded_sections.retain(|s| s != "keywording");
|
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)
|
Ok(Value::Null)
|
||||||
}
|
}
|
||||||
"panel.versions" => {
|
"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.add_space(8.0);
|
||||||
ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text));
|
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| {
|
egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||||
for (sc, label) in fixed {
|
for (sc, label) in fixed {
|
||||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
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
|
// the Keyword List is the library's, not a photo's: it is there without a selection
|
||||||
if app.ui.right == RightPanel::Keywords {
|
if app.ui.right == RightPanel::Keywords {
|
||||||
// (without a selection there is no Add keyword box for Add Keywords to put the cursor in)
|
// (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| {
|
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||||
ui.spacing_mut().item_spacing.y = 0.0;
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
if fold_header(app, ui, "keywording", "Keywords") {
|
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 for_selected = several && app.ui.settings.info_selected_photos;
|
||||||
let m = shown_meta(&app.session.catalog, &p, &selected, for_selected);
|
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() };
|
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| {
|
padded(ui, |ui| {
|
||||||
if several {
|
if several {
|
||||||
metadata_for(app, ui);
|
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),
|
("Extended Description", "extendedDescription", &m.extended_description, 3),
|
||||||
("Copyright", "copyright", &m.copyright, 1),
|
("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 [
|
for (label, key, value, lines) in [
|
||||||
("Rights Usage Terms", "usageTerms", &m.usage_terms, 2),
|
("Rights Usage Terms", "usageTerms", &m.usage_terms, 2),
|
||||||
("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1),
|
("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1),
|
||||||
("Creator", "creator", &m.creator, 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)
|
// 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));
|
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),
|
("State / Province", "state", &m.state),
|
||||||
("Country", "country", &m.country),
|
("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
|
// GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it
|
||||||
let gps_mixed = mixed.contains(&"gps");
|
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 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");
|
let gid = egui::Id::new("info-gps");
|
||||||
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
|
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 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);
|
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;
|
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
|
/// 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
|
/// 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
|
/// when what the panel shows changed during the edit. It saves to the photos it edited when the
|
||||||
/// edit started, also when the selection changed since.
|
/// edit started (the selected photos, or Target Photo's photo alone), also when the selection
|
||||||
|
/// changed since.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FieldEdit {
|
struct FieldEdit {
|
||||||
text: String,
|
text: String,
|
||||||
start: 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>,
|
ids: Vec<u64>,
|
||||||
/// The field said Mixed when the edit started.
|
/// The field said Mixed when the edit started.
|
||||||
mixed: bool,
|
mixed: bool,
|
||||||
@@ -931,8 +934,8 @@ struct FieldEdit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FieldEdit {
|
impl FieldEdit {
|
||||||
fn new(value: &str, mixed: bool, selected: &[PhotoId]) -> Self {
|
fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self {
|
||||||
let ids = selected.iter().map(|id| id.0).collect();
|
let ids = targets.iter().map(|id| id.0).collect();
|
||||||
FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false }
|
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
|
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the
|
||||||
/// selected photos' statuses differ, and any status picked is given to all of them.
|
/// selected photos' statuses differ, and any status picked is given to all of them. A status
|
||||||
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool) {
|
/// 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());
|
let t = Tokens::get(ui.ctx());
|
||||||
ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim));
|
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() });
|
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 {
|
for st in lightcraft_catalog::CopyrightStatus::ALL {
|
||||||
let on = !mixed && st == current;
|
let on = !mixed && st == current;
|
||||||
if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on {
|
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
|
/// 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
|
/// 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
|
/// 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).
|
/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`.
|
||||||
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool) {
|
#[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());
|
let t = Tokens::get(ui.ctx());
|
||||||
ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
|
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 id = egui::Id::new(("info-field", key));
|
||||||
let value = if mixed { "" } else { value };
|
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 widget = format!("field:{key}");
|
||||||
let field = if lines > 1 {
|
let field = if lines > 1 {
|
||||||
crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines)
|
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 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));
|
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
|
// 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)
|
let r = crate::text_field::TextField::singleline("field:keyword", &mut text)
|
||||||
.hint(crate::i18n::tr("Add keyword"))
|
.hint(crate::i18n::tr("Add keyword"))
|
||||||
.width(f32::INFINITY)
|
.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
|
/// 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.
|
/// 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)?)))
|
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,
|
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).
|
/// How long Show Briefly When Photo Changes shows the info overlay (seconds).
|
||||||
pub const BRIEF_INFO_SECS: f64 = 3.0;
|
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…).
|
/// Focus the search field on the next frame (Edit → Find…).
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub focus_search: bool,
|
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)]
|
#[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.
|
/// A mask being renamed in the Masks list: its id and the edited name.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub renaming_mask: Option<(u32, String)>,
|
pub renaming_mask: Option<(u32, String)>,
|
||||||
@@ -1542,7 +1548,7 @@ impl Default for UiState {
|
|||||||
known_cards: None,
|
known_cards: None,
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
focus_search: false,
|
focus_search: false,
|
||||||
focus_keywords: false,
|
focus_keywords: None,
|
||||||
renaming_mask: None,
|
renaming_mask: None,
|
||||||
describe: None,
|
describe: None,
|
||||||
detail_due: 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");
|
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
|
/// 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.
|
/// on the selection: on when every selected photo has it, partly on when only some do.
|
||||||
#[test]
|
#[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
|
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}`;
|
`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,
|
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.
|
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 |
|
| 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-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-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 |
|
| 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