diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 827b367f8..8cdb32462 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -228,6 +228,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen save_curve_preset_file: None, pick_slideshow_templates: None, save_slideshow_template: None, + pick_print_templates: None, + save_print_template: None, write: Some(Box::new(download)), // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7d30e69cd..2193666fd 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -706,6 +706,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_print_templates: Some(Box::new(|| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_print_template: Some(Box::new(|name: &str| { + file_dialog() + .set_title(lightcraft_ui_egui::i18n::tr("Export Print Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), // atomic (temp file + sync + rename): a failed write never leaves a truncated file write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), write: Some(Box::new(lightcraft_engine::export::write_file)), diff --git a/contributors/people.toml b/contributors/people.toml index 0b7f9ee43..95e58421a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -70,3 +70,7 @@ sync_github_name = true [people.ronibalthazar] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 07dcbe0ec..ebed426fa 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -154,6 +154,16 @@ pub fn keyword_sets_json(s: &Session) -> Value { json!({"sets": sets, "current": s.keyword_set.clone().unwrap_or_else(|| RECENT.into()), "keywords": current_keywords(s)}) } +/// Make the set `step` places after the current one current, in the Keyword Set menu's order +/// (Recent Keywords, then the saved sets), wrapping around from the last to the first and back. +fn step_set(s: &mut Session, step: isize) -> Result { + let at = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().position(|x| same_set(&x.name, n))).map_or(0, |i| i + 1); + let next = (at as isize + step).rem_euclid(s.keyword_sets.len() as isize + 1) as usize; + s.keyword_set = next.checked_sub(1).and_then(|i| s.keyword_sets.get(i)).map(|x| x.name.clone()); + s.save_prefs()?; + Ok(keyword_sets_json(s)) +} + pub fn specs() -> Vec { vec![ cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))), @@ -174,6 +184,24 @@ pub fn specs() -> Vec { s.save_prefs()?; Ok(keyword_sets_json(s)) }), + cmd!( + "keyword.nextSet", + "Next Keyword Set", + [], + Some("Alt+0"), + "{}: the set after the current one (after the last, Recent Keywords)", + always, + |s, _| step_set(s, 1) + ), + cmd!( + "keyword.previousSet", + "Previous Keyword Set", + [], + Some("Alt+Shift+0"), + "{}: the set before the current one (before Recent Keywords, the last)", + always, + |s, _| step_set(s, -1) + ), cmd!( "keyword.saveSet", "Save Keyword Set", diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 9d11cc5b3..25a03eab8 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -726,7 +726,7 @@ pub fn specs() -> Vec { cmd!(query "library.devices", "Cameras and Cards", [], None, "{} → [{name, path (its DCIM folder), root}] — mounted volumes with a DCIM folder", always, |_, _| { Ok(serde_json::to_value(crate::devices::devices_now()).unwrap_or_default()) }), - cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], None, "{} — title, caption, copyright (notice, status, usage terms, info URL), creator, location and keywords of the active photo", has_active, |s, _| { + cmd!(query "photo.copyMetadata", "Copy Metadata", ["Photo"], Some("Cmd+Alt+Shift+C"), "{} — title, caption, copyright (notice, status, usage terms, info URL), 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, "altText": m.alt_text, "extendedDescription": m.extended_description, @@ -740,7 +740,7 @@ pub fn specs() -> Vec { "photo.pasteMetadata", "Paste Metadata", ["Photo"], - None, + Some("Cmd+Alt+Shift+V"), "{ids?, fields?: [title|caption|copyright|copyrightStatus|usageTerms|copyrightUrl|creator|location|keywords] (default: all copied)}", has_selection, |s, p| { diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 26f5d9bc4..1390ca167 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -8,12 +8,12 @@ //! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` //! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` //! * `print.exportPdf {path, ids?}` -//! * `print.template.list|save|apply|delete` +//! * `print.template.list|save|apply|delete`, `print.template.export|import` (`.lcprint` files) use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, f64_req}; use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; -use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::print::template::{FILE_EXT, MAX_FILE, MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name, unique_name}; use crate::{Result, Session}; fn rect_json(r: &Rect) -> Value { @@ -277,10 +277,21 @@ fn template_name(p: &Value, id: &str) -> Result { clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e)) } +/// Save the preferences after a change to the templates; if that fails, the templates go back to +/// `before`, so the list matches what is saved and a retry does the same thing. +fn save_templates(s: &mut Session, before: Vec) -> Result<()> { + if let Err(e) = s.save_prefs() { + s.print_templates = before; + return Err(e); + } + Ok(()) +} + fn template_save(s: &mut Session, p: &Value) -> Result { const ID: &str = "print.template.save"; let name = template_name(p, ID)?; let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?; + let before = s.print_templates.clone(); match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { Some(x) => *x = t, None => { @@ -290,7 +301,7 @@ fn template_save(s: &mut Session, p: &Value) -> Result { s.print_templates.push(t); } } - s.save_prefs()?; + save_templates(s, before)?; Ok(template_list(s)) } @@ -309,15 +320,66 @@ fn template_apply(s: &mut Session, p: &Value) -> Result { fn template_delete(s: &mut Session, p: &Value) -> Result { const ID: &str = "print.template.delete"; let name = template_name(p, ID)?; - let before = s.print_templates.len(); + let before = s.print_templates.clone(); s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name)); - if s.print_templates.len() == before { + if s.print_templates.len() == before.len() { return Err(bad(ID, format!("no print template `{name}`"))); } - s.save_prefs()?; + save_templates(s, before)?; Ok(template_list(s)) } +fn template_path<'a>(p: &'a Value, id: &str) -> Result<&'a str> { + p.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(id, "missing `path`")) +} + +fn template_export(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.export"; + let name = template_name(p, ID)?; + let mut path = std::path::PathBuf::from(template_path(p, ID)?); + if path.extension().is_none() { + path.set_extension(FILE_EXT); + } + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + let bytes = serde_json::to_vec_pretty(&t.to_file()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + let name = t.name.clone(); + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(ID, e))?; + crate::export::write_file(&target, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"name": name, "path": target})) +} + +fn template_import(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.import"; + let path = std::path::Path::new(template_path(p, ID)?); + let meta = std::fs::metadata(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(bad(ID, format!("{}: not a file", path.display()))); + } + if meta.len() > MAX_FILE { + return Err(bad(ID, format!("{}: {} bytes is too large for a print template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024))); + } + let bytes = std::fs::read(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + let mut t = PrintTemplate::from_file(&bytes).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?; + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + let name = unique_name(&t.name, &s.print_templates); + let renamed = name != t.name; + t.name = name.clone(); + let before = s.print_templates.clone(); + s.print_templates.push(t); + save_templates(s, before)?; + let mut out = template_list(s); + out["name"] = json!(name); + out["renamed"] = json!(renamed); + Ok(out) +} + pub fn specs() -> Vec { vec![ cmd!(query "print.get", "Print Settings", [], None, @@ -349,5 +411,11 @@ pub fn specs() -> Vec { always, template_save), cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + cmd!(query "print.template.export", "Export Print Template", [], None, + "{name, path}: writes the template as a .lcprint JSON file ({format, version, name, settings}; `.lcprint` is added when the path has no extension); replaces an existing file, never an original → {name, path}", + always, template_export), + cmd!(query "print.template.import", "Import Print Template", [], None, + "{path: a .lcprint file (at most 256 KB)}: checked like a saved template (format, version, name, every setting; cell photos and guides are dropped) and added under its name; a name taken (any case) gets \" (2)\"… → print.template.list + {name, renamed}", + always, template_import), ] } diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 33ff76ec0..191cee953 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -379,7 +379,7 @@ pub fn specs() -> Vec { "Slideshow Playback Settings", [], None, - "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due), manual?: bool (manual slideshow: a slide stays up until ← or → changes it)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual}", always, playback ), diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs index 2465c123a..7bb533612 100644 --- a/crates/engine/src/print/template.rs +++ b/crates/engine/src/print/template.rs @@ -30,6 +30,14 @@ impl PrintSettings { pub const MAX_NAME: usize = 80; /// Most templates kept. pub const MAX_TEMPLATES: usize = 200; +/// File extension of an exported print template (JSON). +pub const FILE_EXT: &str = "lcprint"; +/// `format` tag of an exported print template file. +const FILE_FORMAT: &str = "lightcraft.printTemplate"; +/// Version of the template file this build writes and the newest it reads. +const FILE_VERSION: u64 = 1; +/// Largest template file read, bytes. +pub const MAX_FILE: u64 = 256 * 1024; /// A saved print template: the layout, image settings, page and job of a print, without the /// photos assigned to cells and without the guides (a view preference). The settings are kept as @@ -74,6 +82,53 @@ impl PrintTemplate { s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; Ok(s) } + + /// The document of an exported template: `{format, version, name, settings}`. + pub fn to_file(&self) -> serde_json::Value { + serde_json::json!({"format": FILE_FORMAT, "version": FILE_VERSION, "name": self.name, "settings": self.settings}) + } + + /// A template read from an exported file. Refused: more than [`MAX_FILE`] bytes, not JSON, + /// another format or a newer version, a bad name, and settings that fail the checks a saved + /// template's settings get. Photos assigned to cells and guides in the file are dropped. + pub fn from_file(bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_FILE { + return Err(format!("{} bytes is too large for a print template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); + } + let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| format!("not a print template file: {e}"))?; + if v.get("format").and_then(serde_json::Value::as_str) != Some(FILE_FORMAT) { + return Err(format!("not a print template file (`format` is not \"{FILE_FORMAT}\")")); + } + match v.get("version").and_then(serde_json::Value::as_u64) { + Some(1..=FILE_VERSION) => {} + Some(n) => return Err(format!("print template file version {n} is not one this version of LightCraft reads (1 to {FILE_VERSION})")), + None => return Err("a print template file needs a `version` number".into()), + } + let name = clean_name(v.get("name").and_then(serde_json::Value::as_str).ok_or("a print template file needs a `name`")?)?; + let settings = v.get("settings").cloned().ok_or("a print template file needs `settings`")?; + let checked = PrintTemplate { name: name.clone(), settings }.settings(&Guides::default())?; + Self::new(&name, &checked) + } +} + +/// `name` if no template in `taken` has it (in any case), else the first free `name (2)`, +/// `name (3)`…, with `name` shortened to keep the result within [`MAX_NAME`] characters. +pub fn unique_name(name: &str, taken: &[PrintTemplate]) -> String { + let free = |n: &str| !taken.iter().any(|t| t.name.eq_ignore_ascii_case(n)); + if free(name) { + return name.to_string(); + } + let mut n = 2usize; + loop { + let suffix = format!(" ({n})"); + let keep = MAX_NAME.saturating_sub(suffix.chars().count()); + let base: String = name.chars().take(keep).collect(); + let candidate = format!("{}{suffix}", base.trim_end()); + if free(&candidate) || n > taken.len() { + return candidate; + } + n += 1; + } } #[cfg(test)] @@ -121,4 +176,64 @@ mod tests { s.job.ppi = f32::NAN; assert!(PrintTemplate::new("t", &s).is_err()); } + + #[test] + fn a_template_file_reads_back_as_the_template() { + let mut s = PrintSettings::default(); + s.layout.style = Style::ContactSheet; + s.page.background = [1, 2, 3]; + let t = PrintTemplate::new("Proofs", &s).unwrap(); + let bytes = serde_json::to_vec(&t.to_file()).unwrap(); + assert_eq!(PrintTemplate::from_file(&bytes).unwrap(), t); + // a hand-written file: photos and guides are not taken in + let doc = json!({"format": FILE_FORMAT, "version": 1, "name": " Hand ", "settings": { + "layout": {"style": "customPackage", "cells": [{"id": 1, "rect": {"x": 1, "y": 1, "w": 2, "h": 2}, "photo": 9}]}, + "guides": {"gridSize": 1.0}}}); + let t = PrintTemplate::from_file(&serde_json::to_vec(&doc).unwrap()).unwrap(); + assert_eq!(t.name, "Hand"); + assert!(t.settings.get("guides").is_none()); + assert!(t.settings.pointer("/layout/cells/0/rect").is_some()); + assert_eq!(t.settings.pointer("/layout/cells/0/photo"), None); + } + + #[test] + fn hostile_template_files_are_refused() { + let file = |v: serde_json::Value| PrintTemplate::from_file(&serde_json::to_vec(&v).unwrap()).unwrap_err(); + let ok = |extra: serde_json::Value| { + let mut v = json!({"format": FILE_FORMAT, "version": 1, "name": "t", "settings": {}}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + v + }; + assert!(PrintTemplate::from_file(b"not json").unwrap_err().contains("not a print template file")); + assert!(PrintTemplate::from_file(&vec![b' '; MAX_FILE as usize + 1]).unwrap_err().contains("too large")); + assert!(file(json!([1, 2])).contains("format")); + assert!(file(ok(json!({"format": "lightcraft.slideshowTemplate"}))).contains("format")); + assert!(file(ok(json!({"version": 2}))).contains("version 2")); + assert!(file(ok(json!({"version": 0}))).contains("version 0")); + assert!(file(ok(json!({"version": "1"}))).contains("`version` number")); + assert!(file(json!({"format": FILE_FORMAT, "name": "t", "settings": {}})).contains("`version` number")); + assert!(file(ok(json!({"name": "a\nb"}))).contains("template name")); + assert!(file(ok(json!({"name": 5}))).contains("`name`")); + assert!(file(json!({"format": FILE_FORMAT, "version": 1, "name": "t"})).contains("`settings`")); + assert!(file(ok(json!({"settings": {"job": {"ppi": 1e30}}}))).contains("template `t`")); + assert!(file(ok(json!({"settings": {"surprise": 1}}))).contains("template `t`")); + assert!(PrintTemplate::from_file(&serde_json::to_vec(&ok(json!({}))).unwrap()).is_ok()); + } + + #[test] + fn an_imported_name_never_takes_an_existing_one() { + let t = |n: &str| PrintTemplate::new(n, &PrintSettings::default()).unwrap(); + assert_eq!(unique_name("Proofs", &[]), "Proofs"); + assert_eq!(unique_name("Proofs", &[t("proofs")]), "Proofs (2)"); + assert_eq!(unique_name("Proofs", &[t("Proofs"), t("PROOFS (2)")]), "Proofs (3)"); + let long = "x".repeat(MAX_NAME); + let named = unique_name(&long, &[t(&long)]); + assert_eq!(named.chars().count(), MAX_NAME); + assert!(named.ends_with(" (2)")); + let many: Vec = std::iter::once(t("A")).chain((2..=MAX_TEMPLATES).map(|n| t(&format!("A ({n})")))).collect(); + let named = unique_name("A", &many); + assert!(!many.iter().any(|x| x.name.eq_ignore_ascii_case(&named)), "{named}"); + } } diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs index dbee505af..cb3a6b97d 100644 --- a/crates/engine/src/slideshow.rs +++ b/crates/engine/src/slideshow.rs @@ -1,6 +1,7 @@ //! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that -//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an -//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]). +//! plays the photos in view (durations, fades, random order, repeat, pause, stepping, manual +//! advance), between an optional intro and ending screen (the Titles panel, +//! [`crate::slide_title`]). //! //! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any //! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline. @@ -39,11 +40,22 @@ pub struct PlaybackSettings { pub repeat: bool, /// Render the next slides before they are due. pub prepare_previews: bool, + /// Manual slideshow: a slide stays up until ← / → (or the controls) change it; the slide + /// duration is not used for playback then. + pub manual: bool, } impl Default for PlaybackSettings { fn default() -> Self { - PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true } + PlaybackSettings { + slide_duration: 4.0, + fade_duration: 1.0, + fade_color: None, + random_order: false, + repeat: true, + prepare_previews: true, + manual: false, + } } } @@ -66,6 +78,7 @@ impl PlaybackSettings { "randomOrder": self.random_order, "repeat": self.repeat, "preparePreviews": self.prepare_previews, + "manual": self.manual, }) } @@ -82,8 +95,11 @@ impl PlaybackSettings { "randomOrder" => s.random_order = boolean(k, v)?, "repeat" => s.repeat = boolean(k, v)?, "preparePreviews" => s.prepare_previews = boolean(k, v)?, + "manual" => s.manual = boolean(k, v)?, other => { - return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)")); + return Err(format!( + "unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual)" + )); } } } @@ -432,8 +448,12 @@ impl Playback { self.started + self.duration(self.current()) } - /// Seconds until the next slide is due (as of `now`; frozen while paused). + /// Seconds until the next slide is due (as of `now`; frozen while paused; infinite in a + /// manual slideshow, where no slide is ever due). pub fn remaining(&self, now: f64) -> f64 { + if self.settings.manual { + return f64::INFINITY; + } (self.due() - self.clock(now)).max(0.0) } @@ -490,7 +510,8 @@ impl Playback { self.started = now; return Tick::Idle; } - if now < self.due() { + // a manual slideshow moves only with `step` (← / →) + if self.settings.manual || now < self.due() { return Tick::Idle; } let Some(next) = self.upcoming(1).first().copied() else { @@ -777,6 +798,30 @@ mod tests { assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); } + #[test] + fn a_manual_slideshow_moves_only_with_the_arrows() { + let s = PlaybackSettings { manual: true, repeat: false, ..settings(1.0, 0.5) }; + let mut p = titled(3, s, Some(1.0), Some(1.0)); + assert_eq!(p.tick(1000.0, all_ready), Tick::Idle, "the intro stays up past its duration"); + assert_eq!(p.current(), Item::INTRO); + assert_eq!(p.remaining(1000.0), f64::INFINITY, "nothing is ever due"); + assert_eq!(p.upcoming(1), vec![ph(1)], "the next slide is still prepared for →"); + assert_eq!(p.step(1, 1000.0), Tick::Advanced(ph(1))); + assert_eq!(p.tick(5000.0, all_ready), Tick::Idle); + assert_eq!(p.current(), ph(1), "a photo stays up too"); + assert_eq!(p.step(-1, 5000.0), Tick::Advanced(Item::INTRO), "← goes back"); + assert_eq!(p.step(4, 5000.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.tick(9000.0, all_ready), Tick::Idle, "the ending stays up until → ends the show"); + assert_eq!(p.step(1, 9000.0), Tick::Ended); + // the setting round-trips and is checked like the others + let d = PlaybackSettings::default(); + assert!(!d.manual, "off by default"); + let m = d.merged(&json!({"manual": true})).unwrap(); + assert!(m.manual); + assert_eq!(m.to_json()["manual"], true); + assert!(d.merged(&json!({"manual": "yes"})).is_err()); + } + #[test] fn a_single_photo_repeats_without_changing() { let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index b98b45de8..94ff59988 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -263,6 +263,29 @@ fn set_names_match_whatever_their_case() { assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted"); } +/// Next / Previous Keyword Set (⌥0 / ⌥⇧0) step through the sets in the Keyword Set menu's order, +/// Recent Keywords first, and wrap around at either end; with no saved sets they stay on Recent +/// Keywords. +#[test] +fn next_and_previous_keyword_set_wrap_around_the_sets() { + let mut s = Session::with_demo(); + let current = |r: serde_json::Value| r["current"].as_str().unwrap_or_default().to_string(); + assert_eq!(current(s.execute("keyword.nextSet", &json!({})).unwrap()), "Recent Keywords", "no sets: nothing to go to"); + assert_eq!(current(s.execute("keyword.previousSet", &json!({})).unwrap()), "Recent Keywords"); + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbor"]})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + let forward: Vec = (0..3).map(|_| current(s.execute("keyword.nextSet", &json!({})).unwrap())).collect(); + assert_eq!(forward, ["Weddings", "Travel", "Recent Keywords"]); + let back: Vec = (0..3).map(|_| current(s.execute("keyword.previousSet", &json!({})).unwrap())).collect(); + assert_eq!(back, ["Travel", "Weddings", "Recent Keywords"]); + let r = s.execute("keyword.nextSet", &json!({})).unwrap(); + assert_eq!(r["keywords"], json!(["ceremony"]), "⌥1–⌥9 now apply the set's keywords"); + // a current set that is no longer there counts as Recent Keywords + s.keyword_set = Some("Gone".into()); + assert_eq!(current(s.execute("keyword.nextSet", &json!({})).unwrap()), "Weddings"); +} + /// Export Keywords writes the keyword list file, and names the keywords Capture One's importer /// refuses (it allows none of ; , < > in a list); without a path it gives the text. #[test] diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 4c9d9582d..c0ded23dd 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -403,7 +403,7 @@ fn slideshow_playback_settings_persist_with_the_library() { let r = s .execute( "slideshow.playback", - &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}), + &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "manual": true}), ) .unwrap(); assert_eq!(r["fadeColor"], "#000000"); @@ -417,7 +417,7 @@ fn slideshow_playback_settings_persist_with_the_library() { let r = s.execute("slideshow.playback", &json!({})).unwrap(); assert_eq!( r, - json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true}) + json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true, "manual": true}) ); drop(s); // out-of-range values written by hand are clamped when the library opens diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 51277da04..5a35adb5b 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -14,6 +14,24 @@ fn fails(s: &mut Session, id: &str, p: Value) -> String { s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string() } +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lightcraft-print-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + fn package(s: &mut Session) { run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}})); } @@ -323,6 +341,125 @@ fn templates_save_apply_delete_and_persist() { assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); } +#[test] +fn templates_export_to_a_file_and_import_from_one() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let scratch = Scratch::new("templates"); + let dir = &scratch.0; + let mut s = open(&files); + run(&mut s, "print.set", json!({"layout": {"style": "contactSheet", "grid": {"columns": 3, "rows": 4}}, "page": {"background": [5, 6, 7]}})); + run(&mut s, "print.template.save", json!({"name": "Proofs"})); + + // the extension is added when the path has none + let r = run(&mut s, "print.template.export", json!({"name": "proofs", "path": dir.join("proofs").to_string_lossy()})); + assert_eq!(r["name"], "Proofs"); + let file = dir.join("proofs.lcprint"); + assert_eq!(r["path"], file.to_string_lossy().as_ref()); + let doc: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some("lightcraft.printTemplate"), Some(1), Some("Proofs"))); + + // into the same library: the name is taken, so the copy is renamed + let r = run(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs (2)"), Some(true)), "{r}"); + assert_eq!(r["templates"].as_array().unwrap().len(), 2); + assert_eq!(r["templates"][1]["settings"], r["templates"][0]["settings"]); + + // into another library, under its own name, and it is kept there + let other = MemStore::new(); + let mut b = open(&other); + let r = run(&mut b, "print.template.import", json!({"path": file.to_string_lossy()})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs"), Some(false))); + let g = run(&mut open(&other), "print.template.apply", json!({"name": "Proofs"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 3); + assert_eq!(g["settings"]["page"]["background"], json!([5, 6, 7])); + + // refusals leave the templates as they were + let bad_file = dir.join("bad.lcprint"); + std::fs::write(&bad_file, br#"{"format": "lightcraft.printTemplate", "version": 1, "name": "Bad", "settings": {"layout": {"pages": 0}}}"#) + .unwrap(); + let huge = dir.join("huge.lcprint"); + std::fs::write(&huge, vec![b' '; crate::print::template::MAX_FILE as usize + 1]).unwrap(); + for (p, why) in [ + (json!({}), "missing `path`"), + (json!({"path": " "}), "missing `path`"), + (json!({"path": dir.join("absent.lcprint").to_string_lossy()}), "absent.lcprint"), + (json!({"path": dir.to_string_lossy()}), "not a file"), + (json!({"path": huge.to_string_lossy()}), "too large"), + (json!({"path": bad_file.to_string_lossy()}), "template `Bad`"), + ] { + let e = fails(&mut s, "print.template.import", p.clone()); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 2); + assert!(fails(&mut s, "print.template.export", json!({"name": "Nope", "path": dir.join("x").to_string_lossy()})).contains("no print template")); + assert!(fails(&mut s, "print.template.export", json!({"name": "Proofs"})).contains("missing `path`")); + assert!(fails(&mut s, "print.template.export", json!({"path": dir.join("x").to_string_lossy()})).contains("missing `name`")); + assert!(!dir.join("x.lcprint").exists()); + + // a full list takes no more + while s.print_templates.len() < crate::print::template::MAX_TEMPLATES { + let n = s.print_templates.len(); + run(&mut s, "print.template.save", json!({"name": format!("T{n}")})); + } + assert!(fails(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})).contains("at most")); +} + +/// A library whose `prefs.json` can't be read, so saving the preferences fails. +#[derive(Clone, Default)] +struct PrefsUnreadable(MemStore); + +impl lightcraft_catalog::Store for PrefsUnreadable { + fn read(&mut self, name: &str) -> std::io::Result>> { + if name == "prefs.json" { + return Err(std::io::Error::other("locked by another process")); + } + self.0.read(name) + } + fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> { + self.0.write_atomic(name, data) + } + fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> { + self.0.append(name, data) + } + fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> { + self.0.truncate(name, len) + } + fn describe(&self) -> String { + "prefs unreadable".into() + } +} + +#[test] +fn template_changes_that_cant_be_saved_are_undone() { + let mut s = Session::with_demo(); + let stores = + LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(PrefsUnreadable::default()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s.print_templates.push(crate::print::template::PrintTemplate::new("Proofs", &s.print).unwrap()); + let names = |s: &Session| s.print_templates.iter().map(|t| (t.name.clone(), t.settings.clone())).collect::>(); + let kept = names(&s); + let scratch = Scratch::new("unsaved"); + let file = scratch.0.join("proofs.lcprint"); + run(&mut s, "print.template.export", json!({"name": "Proofs", "path": file.to_string_lossy()})); + + run(&mut s, "print.set", json!({"layout": {"style": "contactSheet"}})); + for (id, p) in [ + ("print.template.save", json!({"name": "Other"})), + ("print.template.save", json!({"name": "proofs"})), + ("print.template.delete", json!({"name": "Proofs"})), + ("print.template.import", json!({"path": file.to_string_lossy()})), + ] { + assert!(fails(&mut s, id, p.clone()).contains("prefs.json"), "{id} {p}"); + assert_eq!(names(&s), kept, "{id} {p}"); + } +} + #[test] fn a_hostile_template_in_the_preferences_does_not_break_the_library() { let files = MemStore::new(); @@ -364,6 +501,8 @@ fn every_print_command_is_registered_with_a_description() { "print.template.save", "print.template.apply", "print.template.delete", + "print.template.export", + "print.template.import", ] { let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); assert!(c.params.len() > 3, "{id}"); diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 16095a43e..52ca94a52 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -47,6 +47,7 @@ "Add in place": "Přidat ze současného umístění", "Add in place: the library references the files where they are; nothing is copied or moved.": "Přidat ze současného umístění: knihovna odkazuje na soubory tam, kde jsou; nic se nekopíruje ani nepřesouvá.", "Add keyword": "Přidat klíčové slovo", + "Add Keywords": "Přidat klíčová slova", "Add number": "Přidat číslo", "Add to": "Přidat do", "Add to Album": "Přidat do alba", @@ -1193,6 +1194,7 @@ "New time": "Nový čas", "New…": "Nový…", "Next Appearance Mode": "Další režim vzhledu", + "Next Keyword Set": "Další sada klíčových slov", "Next Photo": "Další fotografie", "Next up for AI Denoise…": "Další na řadě pro redukci šumu pomocí AI…", "Night Blue": "Noční modř", @@ -1402,6 +1404,7 @@ "Preview size": "Velikost náhledu", "Preview unavailable": "Náhled není dostupný", "Previews": "Náhledy", + "Previous Keyword Set": "Předchozí sada klíčových slov", "Previous Photo": "Předchozí fotografie", "Primaries (x, y)": "Primární barvy (x, y)", "Print": "Tisk", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 679e3b00f..e2205db78 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -49,6 +49,7 @@ "Add in place": "Am Speicherort hinzufügen", "Add in place: the library references the files where they are; nothing is copied or moved.": "Am Speicherort hinzufügen: Die Bibliothek verweist auf die Dateien an ihrem aktuellen Speicherort; nichts wird kopiert oder verschoben.", "Add keyword": "Stichwort hinzufügen", + "Add Keywords": "Stichwörter hinzufügen", "Add number": "Nummer hinzufügen", "Add to": "Hinzufügen zu", "Add to Album": "Zu Album hinzufügen", @@ -950,6 +951,7 @@ "New name": "Neuer Name", "New time": "Neue Zeit", "New…": "Neu…", + "Next Keyword Set": "Nächster Stichwortsatz", "Next Photo": "Nächstes Foto", "Night Blue": "Nachtblau", "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon-NEFs beginnen mit einem Farb- und Tonwertbild, das wie bei Sony-ARWs an das Kamera-JPEG angepasst ist, statt mit einem gedämpften,", @@ -1112,6 +1114,7 @@ "Preview size": "Vorschaugröße", "Preview unavailable": "Vorschau nicht verfügbar", "Previews": "Vorschauen", + "Previous Keyword Set": "Vorheriger Stichwortsatz", "Previous Photo": "Vorheriges Foto", "Print": "Drucken", "Print…": "Drucken…", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index af22570ef..888af077f 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -34,6 +34,7 @@ "Add in place": "Añadir sin mover", "Add in place: the library references the files where they are; nothing is copied or moved.": "Añadir sin mover: la biblioteca hace referencia a los archivos donde están; no se copia ni se mueve nada.", "Add keyword": "Añadir palabra clave", + "Add Keywords": "Añadir palabras clave", "Add number": "Añadir número", "Add to": "Añadir a", "Add to Album": "Añadir al álbum", @@ -961,6 +962,7 @@ "New name": "Nuevo nombre", "New time": "Nueva hora", "New…": "Nuevo…", + "Next Keyword Set": "Conjunto de palabras clave siguiente", "Next Photo": "Foto siguiente", "Night Blue": "Azul nocturno", "No": "No", @@ -1117,6 +1119,7 @@ "Preview size": "Tamaño de la vista previa", "Preview unavailable": "Vista previa no disponible", "Previews": "Vistas previas", + "Previous Keyword Set": "Conjunto de palabras clave anterior", "Previous Photo": "Foto anterior", "Print": "Imprimir", "Print…": "Imprimir…", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 6456a6624..e0a8c622a 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -58,6 +58,7 @@ "Add in place": "Lisää paikalleen", "Add in place: the library references the files where they are; nothing is copied or moved.": "Lisää paikalleen: kirjasto viittaa tiedostoihin niiden nykyisessä sijainnissa; mitään ei kopioida tai siirretä.", "Add keyword": "Lisää avainsana", + "Add Keywords": "Lisää avainsanoja", "Add number": "Lisää numero", "Add text overlay": "Lisää tekstipeittokuva", "Add to": "Lisää kohteeseen", @@ -1236,6 +1237,7 @@ "New…": "Uusi…", "Next Appearance Mode": "Seuraava ulkoasutila", "Next History Step": "Seuraava historiavaihe", + "Next Keyword Set": "Seuraava avainsanajoukko", "Next Photo": "Seuraava kuva", "Next Slide": "Seuraava dia", "Next page": "Seuraava sivu", @@ -1461,6 +1463,7 @@ "Preview unavailable": "Esikatselu ei käytettävissä", "Previews": "Esikatselut", "Previous History Step": "Edellinen historiavaihe", + "Previous Keyword Set": "Edellinen avainsanajoukko", "Previous Photo": "Edellinen kuva", "Previous Slide": "Edellinen dia", "Previous page": "Edellinen sivu", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 33a892ecf..3278d6ccd 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -34,6 +34,7 @@ "Add in place": "Ajouter sur place", "Add in place: the library references the files where they are; nothing is copied or moved.": "Ajouter sur place : la bibliothèque référence les fichiers là où ils se trouvent ; rien n'est copié ni déplacé.", "Add keyword": "Ajouter un mot-clé", + "Add Keywords": "Ajouter des mots-clés", "Add number": "Ajouter un numéro", "Add to": "Ajouter à", "Add to Album": "Ajouter à l'album", @@ -904,6 +905,7 @@ "New name": "Nouveau nom", "New time": "Nouvelle heure", "New…": "Nouveau…", + "Next Keyword Set": "Jeu de mots-clés suivant", "Next Photo": "Photo suivante", "Night Blue": "Bleu nuit", "No": "Non", @@ -1059,6 +1061,7 @@ "Preview size": "Taille de l'aperçu", "Preview unavailable": "Aperçu indisponible", "Previews": "Aperçus", + "Previous Keyword Set": "Jeu de mots-clés précédent", "Previous Photo": "Photo précédente", "Print": "Imprimer", "Print…": "Imprimer…", diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index abb6a732e..0d6da9b8f 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -58,6 +58,7 @@ "Add in place": "Tambahkan di lokasi asli", "Add in place: the library references the files where they are; nothing is copied or moved.": "Tambahkan di lokasi asli: pustaka merujuk file di lokasinya saat ini; tidak ada yang disalin atau dipindahkan.", "Add keyword": "Tambahkan kata kunci", + "Add Keywords": "Tambahkan kata kunci", "Add number": "Tambahkan nomor", "Add text overlay": "Tambahkan overlay teks", "Add to": "Tambahkan ke", @@ -1233,6 +1234,7 @@ "New…": "Baru…", "Next Appearance Mode": "Mode tampilan berikutnya", "Next History Step": "Langkah riwayat berikutnya", + "Next Keyword Set": "Set kata kunci berikutnya", "Next Photo": "Foto berikutnya", "Next Slide": "Slide berikutnya", "Next up for AI Denoise…": "Berikutnya dalam antrean pengurangan noise AI…", @@ -1445,6 +1447,7 @@ "Preview unavailable": "Pratinjau tidak tersedia", "Previews": "Pratinjau", "Previous History Step": "Langkah riwayat sebelumnya", + "Previous Keyword Set": "Set kata kunci sebelumnya", "Previous Photo": "Foto sebelumnya", "Previous Slide": "Slide sebelumnya", "Previous settings": "Pengaturan sebelumnya", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index a75448fbb..e159fd47d 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -34,6 +34,7 @@ "Add in place": "Aggiungi sul posto", "Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.", "Add keyword": "Aggiungi parola chiave", + "Add Keywords": "Aggiungi parole chiave", "Add number": "Aggiungi numero", "Add to": "Aggiungi a", "Add to Album": "Aggiungi all'album", @@ -907,6 +908,7 @@ "New name": "Nuovo nome", "New time": "Nuova ora", "New…": "Nuovo…", + "Next Keyword Set": "Set di parole chiave successivo", "Next Photo": "Foto successiva", "Night Blue": "Blu notte", "No": "No", @@ -1068,6 +1070,7 @@ "Preview size": "Dimensione dell'anteprima", "Preview unavailable": "Anteprima non disponibile", "Previews": "Anteprime", + "Previous Keyword Set": "Set di parole chiave precedente", "Previous Photo": "Foto precedente", "Print": "Stampa", "Print…": "Stampa…", @@ -2333,9 +2336,11 @@ "View Options": "Opzioni di visualizzazione", "Loupe View": "Vista lente", "Show Info Overlay": "Mostra sovrapposizione informazioni", + "Show message when loading or rendering photos": "Mostra un messaggio durante il caricamento o il rendering delle foto", "Loupe Info 1": "Info lente 1", "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", + "Show briefly when photo changes": "Mostra brevemente al cambio di foto", "Camera & Lens": "Fotocamera e obiettivo", "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.", "Before/After Side by Side": "Prima/Dopo affiancati", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c3f11e6e5..e1834555b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -35,6 +35,7 @@ "Add in place": "現在の場所から追加", "Add in place: the library references the files where they are; nothing is copied or moved.": "現在の場所のファイルを参照します。コピーや移動はしません", "Add keyword": "キーワードを追加", + "Add Keywords": "キーワードを追加", "Add number": "番号を付ける", "Add to": "追加先", "Add to Album": "アルバムに追加", @@ -632,6 +633,7 @@ "Nested group": "入れ子のグループ", "New Album": "新しいアルバム", "New time": "新しい日時", + "Next Keyword Set": "次のキーワードセット", "Next Photo": "次の写真", "Night Blue": "ナイトブルー", "No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません", @@ -672,6 +674,7 @@ "Preview Build Progress": "プレビュー作成の進行状況", "Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中", "Preview unavailable": "プレビューを表示できません", + "Previous Keyword Set": "前のキーワードセット", "Previous Photo": "前の写真", "Profile Browser": "プロファイルブラウザー", "Profile Menu": "プロファイルメニュー", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 13fd48d3c..32c407700 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -110,6 +110,7 @@ "Add in place": "ადგილზე დამატება", "Add in place: the library references the files where they are; nothing is copied or moved.": "ადგილზე დამატება: ბიბლიოთეკა ფაილებს მათ ამჟამინდელ ადგილას მიმართავს; არაფერი კოპირდება ან გადაიტანება.", "Add keyword": "საკვანძო სიტყვის დამატება", + "Add Keywords": "საკვანძო სიტყვების დამატება", "Add number": "რიცხვის დამატება", "Add to": "დამატება:", "Add to Album": "ალბომში დამატება", @@ -1310,6 +1311,7 @@ "New…": "ახალი…", "Next Appearance Mode": "შემდეგი იერსახის რეჟიმი", "Next History Step": "ისტორიის შემდეგი ნაბიჯი", + "Next Keyword Set": "საკვანძო სიტყვების შემდეგი ნაკრები", "Next Photo": "შემდეგი ფოტო", "Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…", "Night Blue": "ღამის ლურჯი", @@ -1534,6 +1536,7 @@ "Previews": "წინასწარი ხედები", "Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე", "Previous History Step": "ისტორიის წინა ნაბიჯი", + "Previous Keyword Set": "საკვანძო სიტყვების წინა ნაკრები", "Previous Photo": "წინა ფოტო", "Primaries (x, y)": "ძირითადი ფერები (x, y)", "Print": "ბეჭდვა", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index f03a81762..adac22e62 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -31,6 +31,7 @@ "Add in place": "Dodaj bez kopiowania plików", "Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.", "Add keyword": "Dodaj słowo kluczowe", + "Add Keywords": "Dodaj słowa kluczowe", "Add number": "Dodaj numer", "Add to": "Dodaj do", "Add to Album": "Dodaj do albumu", @@ -886,6 +887,7 @@ "New name": "Nowa nazwa", "New time": "Nowy czas", "New…": "Nowy…", + "Next Keyword Set": "Następny zestaw słów kluczowych", "Next Photo": "Następne zdjęcie", "Night Blue": "Nocny błękit", "No": "Nie", @@ -1030,6 +1032,7 @@ "Preview only": "Tylko podgląd", "Preview size": "Rozmiar podglądu", "Previews": "Podglądy", + "Previous Keyword Set": "Poprzedni zestaw słów kluczowych", "Previous Photo": "Poprzednie zdjęcie", "Print": "Drukuj", "Print…": "Drukuj…", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 88e955d26..e84fb7615 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -36,6 +36,7 @@ "Add in place": "Adicionar no local", "Add in place: the library references the files where they are; nothing is copied or moved.": "Adicionar no local: a biblioteca referencia os arquivos onde eles estão; nada é copiado nem movido.", "Add keyword": "Adicionar palavra-chave", + "Add Keywords": "Adicionar palavras-chave", "Add number": "Adicionar número", "Add to": "Adicionar a", "Add to Album": "Adicionar ao álbum", @@ -916,6 +917,7 @@ "New name": "Novo nome", "New time": "Novo horário", "New…": "Novo…", + "Next Keyword Set": "Próximo conjunto de palavras-chave", "Next Photo": "Próxima foto", "Night Blue": "Azul noturno", "No": "Não", @@ -1077,6 +1079,7 @@ "Preview size": "Tamanho da pré-visualização", "Preview unavailable": "Prévia indisponível", "Previews": "Pré-visualizações", + "Previous Keyword Set": "Conjunto de palavras-chave anterior", "Previous Photo": "Foto anterior", "Print": "Imprimir", "Print…": "Imprimir…", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5c8816c5e..306804df0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -20,6 +20,7 @@ "Add in place": "Добавить на месте", "Add in place: the library references the files where they are; nothing is copied or moved.": "Добавить на месте: библиотека ссылается на файлы там, где они лежат; ничего не копируется и не перемещается.", "Add keyword": "Добавить ключевое слово", + "Add Keywords": "Добавить ключевые слова", "Add number": "Добавить номер", "Add to": "Добавить в", "Add to Album": "Добавить в альбом", @@ -760,6 +761,7 @@ "New blank PDF": "Новый пустой PDF", "New name": "Новое имя", "New…": "Новый…", + "Next Keyword Set": "Следующий набор ключевых слов", "Next Photo": "Следующее фото", "Night Blue": "Ночной синий", "No": "Нет", @@ -897,6 +899,7 @@ "Preview size": "Размер просмотра", "Preview unavailable": "Просмотр недоступен", "Previews": "Просмотры", + "Previous Keyword Set": "Предыдущий набор ключевых слов", "Previous Photo": "Предыдущее фото", "Print": "Печать", "Print…": "Печать…", diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index d9af4b8e8..66e69b7ba 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -60,6 +60,7 @@ "Add in place": "Lägg till på plats", "Add in place: the library references the files where they are; nothing is copied or moved.": "Lägg till på plats: biblioteket refererar till filerna där de ligger; ingenting kopieras eller flyttas.", "Add keyword": "Lägg till nyckelord", + "Add Keywords": "Lägg till nyckelord", "Add Mask": "Lägg till mask", "Add number": "Lägg till nummer", "Add Object Click": "Lägg till objektklick", @@ -1362,6 +1363,7 @@ "New…": "Ny…", "Next Appearance Mode": "Nästa utseendeläge", "Next page": "Nästa sida", + "Next Keyword Set": "Nästa nyckelordsuppsättning", "Next Photo": "Nästa foto", "Next up for AI Denoise…": "Näst i tur för AI-brusreducering…", "Night Blue": "Nattblå", @@ -1599,6 +1601,7 @@ "Previews": "Förhandsgranskningar", "previews": "förhandsgranskningar", "Previous page": "Föregående sida", + "Previous Keyword Set": "Föregående nyckelordsuppsättning", "Previous Photo": "Föregående foto", "Previous settings": "Föregående inställningar", "Primaries (x, y)": "Primärfärger (x, y)", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 39090c0b3..ee694ed4f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -389,6 +389,7 @@ "Add in place": "Додати на місці", "Add in place: the library references the files where they are; nothing is copied or moved.": "Додати на місці: бібліотека посилається на файли в їхньому розташуванні; нічого не копіюється й не переміщується.", "Add keyword": "Додати ключове слово", + "Add Keywords": "Додати ключові слова", "Add number": "Додати число", "Add to Album": "Додати до альбому", "Add to Favorites": "Додати до улюблених", @@ -855,6 +856,7 @@ "Nested group": "Вкладена група", "New Album": "Новий альбом", "New time": "Новий час", + "Next Keyword Set": "Наступний набір ключових слів", "Next Photo": "Наступне фото", "Night Blue": "Нічна синява", "No named people in these photos": "На цих фото немає людей з іменами", @@ -887,6 +889,7 @@ "Presets and profiles": "Набори налаштувань і профілі", "Preview Build Progress": "Перебіг створення переглядів", "Preview only: editing the camera's embedded JPEG": "Лише перегляд: редагування вбудованого JPEG камери", + "Previous Keyword Set": "Попередній набір ключових слів", "Previous Photo": "Попереднє фото", "Profile Browser": "Оглядач профілів", "Profile Menu": "Меню профілів", diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index 98adc91b8..e4fb8feb8 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -60,6 +60,7 @@ "Add in place": "Thêm tại chỗ", "Add in place: the library references the files where they are; nothing is copied or moved.": "Thêm tại chỗ: thư viện tham chiếu tệp ở vị trí hiện tại; không sao chép hay di chuyển gì.", "Add keyword": "Thêm từ khóa", + "Add Keywords": "Thêm từ khóa", "Add number": "Thêm số", "Add to": "Thêm vào", "Add to Album": "Thêm vào album", @@ -1136,6 +1137,7 @@ "New…": "Mới…", "Next Appearance Mode": "Chế độ giao diện tiếp theo", "Next History Step": "Bước lịch sử sau", + "Next Keyword Set": "Bộ từ khóa tiếp theo", "Next Photo": "Ảnh tiếp theo", "Next up for AI Denoise…": "Tiếp theo cho AI Khử nhiễu…", "Night Blue": "Xanh đêm", @@ -1338,6 +1340,7 @@ "Preview unavailable": "Không có xem trước", "Previews": "Xem trước", "Previous History Step": "Bước lịch sử trước", + "Previous Keyword Set": "Bộ từ khóa trước", "Previous Photo": "Ảnh trước", "Primaries (x, y)": "Màu gốc (x, y)", "Print": "In", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 73caf6407..270af8a62 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -36,6 +36,7 @@ "Add in place": "在原位置添加", "Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。", "Add keyword": "添加关键字", + "Add Keywords": "添加关键字", "Add number": "添加编号", "Add to": "添加到", "Add to Album": "添加到相册", @@ -981,6 +982,7 @@ "New blank PDF": "新建空白 PDF", "New name": "新名称", "New…": "新建…", + "Next Keyword Set": "下一个关键字集", "Next Photo": "下一张照片", "Night Blue": "夜色蓝调", "No": "否", @@ -1109,6 +1111,7 @@ "Preview only": "仅预览", "Preview size": "预览大小", "Previews": "预览", + "Previous Keyword Set": "上一个关键字集", "Previous Photo": "上一张照片", "Print": "打印", "Print…": "打印…", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 72cc3ab7f..0408e23ec 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -36,6 +36,7 @@ "Add in place": "原地加入", "Add in place: the library references the files where they are; nothing is copied or moved.": "原地加入:照片庫參照檔案目前的位置,不會複製或移動檔案。", "Add keyword": "新增關鍵字", + "Add Keywords": "新增關鍵字", "Add number": "加上編號", "Add to": "加入至", "Add to Album": "加入相簿", @@ -982,6 +983,7 @@ "New blank PDF": "新增空白 PDF", "New name": "新名稱", "New…": "新增…", + "Next Keyword Set": "下一個關鍵字集", "Next Photo": "下一張照片", "Night Blue": "夜色藍調", "No": "否", @@ -1110,6 +1112,7 @@ "Preview only": "僅供預覽", "Preview size": "預覽尺寸", "Previews": "預覽", + "Previous Keyword Set": "上一個關鍵字集", "Previous Photo": "上一張照片", "Print": "列印", "Print…": "列印…", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f3d8d94cb..4b530ad57 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2926,6 +2926,260 @@ mod tests { assert_eq!(meta(&h).title, "Wedding in Sintra"); } + /// With several photos selected the Info panel offers Target Photo / Selected Photos. Target + /// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose + /// values differ empty, so what is typed there is what every selected photo gets, and keeps + /// the value of a field they share; leaving a mixed field untouched saves nothing. + #[test] + fn info_shows_metadata_for_the_selected_photos() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + r + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone() + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1])); + let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + h.settle(SETTLE); + let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string(); + assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected"); + assert!(h.app.ui.settings.info_selected_photos, "Selected Photos by default"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn!"})); + // Target Photo and back to Selected Photos, from their buttons + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-target"})); + assert!(!h.app.ui.settings.info_selected_photos); + ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"})); + assert!(h.app.ui.settings.info_selected_photos); + // a mixed field left untouched saves nothing + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo)); + // neither does switching the choice while a field is being edited, either way + for on in [false, true] { + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": on})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}"); + } + // the mixed title starts empty; the shared creator keeps its value + for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] { + ask(&mut h, "ui.clickWidget", json!({"id": format!("field:{key}")})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": typed})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator }; + assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}"); + } + // a mixed field typed in and left empty clears it on every selected photo + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + ask(&mut h, "ui.text", json!({"text": "x"})); + ask(&mut h, "ui.key", json!({"key": "Backspace"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new())); + // GPS: one photo's place and Show on Map only while the selected photos share it + let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap"); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]})); + h.settle(SETTLE); + assert!(!map(&mut h), "places differ"); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]})); + h.settle(SETTLE); + assert!(map(&mut h), "a shared place"); + // the command: refuses a value that isn't true or false, and turns Target Photo back on + let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false); + assert!(!h.app.ui.settings.info_selected_photos); + assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles"); + // the choice is saved with the app settings; settings saved before it read as Selected Photos + exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(saved["infoSelectedPhotos"], false); + let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap(); + assert!(!read.info_selected_photos); + let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap(); + assert!(old.info_selected_photos); + // one photo selected: no choice to make + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + h.settle(SETTLE); + assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h)); + } + + /// With several photos selected and the settings as they come, an edit to a field where the + /// photos differ starts from an empty field: the photos get what was typed, not the active + /// photo's value with the typing added. + #[test] + fn info_edits_of_a_multi_selection_start_from_mixed_fields_by_default() { + 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 = 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]})); + 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": "Harbor"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".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] + fn info_mixed_fields_are_the_ones_that_differ() { + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let all = |key: &str, v: serde_json::Value| json!({"ids": ids, key: v}); + // (every field the panel compares starts the same on the three photos) + let fields = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "state", + "country", + ]; + for key in fields { + s.execute("photo.setMeta", &all(key, json!(""))).unwrap(); + } + for (key, v) in + [("title", json!("Same")), ("city", json!("Porto")), ("copyrightStatus", json!("copyrighted")), ("gps", json!([41.15, -8.61]))] + { + s.execute("photo.setMeta", &all(key, v)).unwrap(); + } + let photos: Vec = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect(); + let shown = s.catalog.photo(photos[0]).unwrap().meta.clone(); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty()); + assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty()); + s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap(); + let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown); + mixed.sort_unstable(); + assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]); + } + + /// Selected Photos with the active photo outside the selection: the fields show what the + /// selected photos share, not the active photo's values. + #[test] + fn info_selected_photos_ignore_an_active_photo_outside_the_selection() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": true})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into())); + } + + /// An Info field saves to the photos selected when its edit started, also when the selection + /// changes while the field is being edited. + #[test] + fn info_field_saves_to_the_photos_selected_when_the_edit_started() { + // (tall enough for the GPS field to show without scrolling) + let mut h = demo([1300.0, 1800.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(4).map(|p| p.0).collect(); + let meta = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": ids, "title": "Same", "gps": ""})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:title"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + exec(&mut h, "library.select", json!({"ids": [ids[2]]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).title, meta(&h, 1).title, meta(&h, 2).title), ("Same!".into(), "Same!".into(), "Same".into())); + // GPS too (the new selection is two photos as well, so the panel keeps its layout) + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:gps"})); + ask(&mut h, "ui.text", json!({"text": "41.15, -8.61"})); + exec(&mut h, "library.select", json!({"ids": [ids[2], ids[3]], "active": ids[2]})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + let gps: Vec = (0..4).map(|i| meta(&h, i).gps.is_some()).collect(); + assert_eq!(gps, [true, true, false, false]); + // Esc gives a GPS edit up + exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]})); + h.settle(SETTLE); + let before = meta(&h, 0).gps; + ask(&mut h, "ui.clickWidget", json!({"id": "field:gps"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "5"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before)); + } + /// Local: a folder's photos show without joining the library; the breadcrumb, Include /// subfolders and Add to My Photos work from the grid header. #[test] @@ -4634,6 +4888,53 @@ mod tests { assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); } + /// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one + /// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows + /// the chosen Info after → changes the photo, and hides it again a few seconds later. + #[test] + fn show_briefly_when_photo_changes_in_view_options() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most"); + h.app.ui.dialog = None; + h.step(); + + let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "hidden once the time has passed"); + let before = h.app.session.active(); + h.request("ui.key", json!({"key": "right"}), t); + assert_ne!(h.app.session.active(), before); + h.step(); + assert!(overlay(&h), "shown after → changes the photo"); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "and hidden again"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}"); + h.request("ui.key", json!({"key": "right"}), t); + h.step(); + assert!(!overlay(&h), "off: nothing shows when the photo changes"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing"); + } + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] @@ -4970,6 +5271,69 @@ mod tests { assert!(!h.app.renderer.is_pending(Slot::Main)); } + /// View Options' Show message when loading or rendering photos (on by default): turned off in + /// the window, the loupe does not say "Rendering…" while an edit renders; `view.viewOptions + /// {loupeMessages}` turns it back on for an agent (refusing a value that is not true or false). + #[test] + fn view_options_turn_the_rendering_message_off() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(h.app.ui.settings.loupe_messages, "on by default"); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.loupeMessages"}), t)["ok"], true); + h.step(); + assert!(!h.app.ui.settings.loupe_messages, "the tick box turns it off"); + h.app.ui.dialog = None; + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "no message while it is off"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": "yes"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert!(!h.app.ui.settings.loupe_messages, "a refused change changes nothing"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": true}}), t); + assert_eq!(r["result"]["loupeMessages"], true, "{r}"); + assert_eq!(h.app.ui.dialog, None, "a value sets it without opening the window"); + assert!(h.step_until(SETTLE, pill), "the message shows again while the render is held"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); + } + + /// With nothing to show yet, the loupe says "Rendering…" in its middle only while View Options' + /// Show message when loading or rendering photos is on. + #[test] + fn an_empty_loupe_says_rendering_only_with_the_message_on() { + use std::{cell::Cell, rc::Rc}; + for on in [true, false] { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.app.ui.settings.loupe_messages = on; + h.app.renderer.set_offload(Box::new(super::RenderGate::new(Rc::new(Cell::new(false))))); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + // drop every texture (quick previews and thumbnails land outside the gate) until a + // frame has nothing to show + let empty = (0..60).any(|_| { + h.app.renderer.textures.clear(); + h.step(); + h.app.loupe_shown.map(|l| l.1) == Some("none") + }); + assert!(empty, "a frame with nothing rendered"); + let said = h.painted_text().iter().any(|text| text == "Rendering…"); + assert_eq!(said, on, "message on: {on}"); + } + } + /// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left /// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the /// error on hover, and keeps saying so while a library change retries it. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 026c5866d..4fc43172c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -300,6 +300,10 @@ pub struct Services { pub pick_slideshow_templates: Option, /// Save dialog for an exported slideshow template (`.json`). pub save_slideshow_template: Option, + /// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…). + pub pick_print_templates: Option, + /// Save dialog for an exported print template (`.lcprint`). + pub save_print_template: Option, pub write: Option, /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). pub write_shared: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..f6691a861 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -139,6 +139,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("local.addRoot", "Add Folder to Local", None, ""), ("local.hide", "Remove from Local", None, ""), ("local.restoreHidden", "Show Hidden Local Locations", None, ""), + // {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos) + ("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""), ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), @@ -158,6 +160,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("panel.presets", "Presets", Some("Shift+P"), "Window"), ("panel.info", "Info", Some("I"), "Window"), ("panel.keywords", "Keywords", Some("K"), "Window"), + // the Keywords panel with the cursor in its Add keyword box + ("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""), ("panel.versions", "Versions", Some("Shift+V"), "Window"), ("panel.activity", "History", None, "Window"), ("panel.close", "Close Panel", None, ""), @@ -749,9 +753,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // `info1` / `info2` set the lines for an agent; without them the window opens + // `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes + // shows and `loupeMessages` Show message when loading or rendering photos, for an agent; + // without any of them the window opens let mut info = app.ui.settings.loupe_info; let mut set = false; + // `loupeMessages` sets Show message when loading or rendering photos + let messages = match p.get("loupeMessages") { + None => app.ui.settings.loupe_messages, + Some(v) => match v.as_bool() { + Some(on) => { + set = true; + on + } + None => return Some(Err("`loupeMessages`: true|false".into())), + }, + }; for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { let Some(v) = p.get(key) else { continue }; match serde_json::from_value(v.clone()) { @@ -762,12 +779,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option info.briefly = v, + Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), + } + set = true; + } if set { app.ui.settings.loupe_info = info; + app.ui.settings.loupe_messages = messages; } else { app.ui.dialog = Some(Dialog::ViewOptions); } - Ok(json!({"loupeInfo": app.ui.settings.loupe_info})) + Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages})) } "view.chooseLayoutImage" => { let path = match p.get("path").and_then(Value::as_str) { @@ -1114,6 +1139,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {on?}; no params toggles. On: the Info panel shows what the selected photos share. + let on = match p.get("on") { + None | Some(Value::Null) => !app.ui.settings.info_selected_photos, + Some(Value::Bool(on)) => *on, + Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))), + }; + app.ui.settings.info_selected_photos = on; + Ok(json!({"on": on})) + } "view.toolOverlay" => { use crate::state::ToolOverlay; let mode = match p.get("mode") { @@ -1242,6 +1277,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // opens Keywording when it is folded shut; the panel stays open when pressed again + if app.ui.right != RightPanel::Keywords { + panel(app, &ctx, RightPanel::Keywords, "Keywords"); + } + app.ui.folded_sections.retain(|s| s != "keywording"); + app.ui.focus_keywords = true; + Ok(Value::Null) + } "panel.versions" => { panel(app, &ctx, RightPanel::Versions, "Versions"); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 444ef7e73..c77c96f42 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -361,6 +361,25 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } +/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and +/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a +/// repaint is asked for then, so it hides without input). +/// +/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS +fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + let now = ctx.input(|i| i.time); + let brief = &mut app.ui.brief_info; + if brief.photo != Some(id) { + brief.photo = Some(id); + brief.since = now; + } + let left = crate::state::BRIEF_INFO_SECS - (now - brief.since); + brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0; + if brief.shown { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } +} + /// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) /// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize /// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high @@ -499,6 +518,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + brief_info(app, ui.ctx(), id); let d = (*photo.develop).clone(); // the full-screen preview shows the photo only: no tool overlays let right = if fullscreen { RightPanel::None } else { app.ui.right }; @@ -824,9 +844,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim); } - None => { + None if app.ui.settings.loupe_messages => { p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); } + None => {} } } } @@ -978,9 +999,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let over = if over.is_positive() { over } else { canvas }; match failed { Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), - None => { + // View Options' Show message when loading or rendering photos + None if app.ui.settings.loupe_messages => { loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); } + None => {} } } if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { @@ -1062,7 +1085,10 @@ fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { /// The info overlay's lines for `photo`, or `None` when it is off. fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option> { use crate::state::InfoOverlay; - let fields = match app.ui.info_overlay { + // with the overlay off, Show Briefly When Photo Changes shows its Info for a moment + let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown; + let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay }; + let fields = match mode { InfoOverlay::Off => return None, InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, @@ -3528,6 +3554,38 @@ mod info_overlay_tests { assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); } + /// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows + /// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays. + #[test] + fn show_briefly_shows_the_info_after_the_photo_changes() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photos: Vec = app.session.catalog.photos().take(2).map(|p| (**p).clone()).collect(); + assert_eq!(photos.len(), 2, "the demo catalog has two photos"); + let ctx = egui::Context::default(); + // the loupe shows photo `i` at `time`: the lines the overlay paints then + let at = |app: &mut LightcraftApp, i: usize, time: f64| { + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, time, vec![]); + let mut out = ctx.run_ui(raw, |ui| brief_info(app, ui.ctx(), photos[i].id)); + out.textures_delta.clear(); + painted_lines(app, &photos[i]) + }; + let name = |i: usize| photos[i].file_name.clone(); + assert!(at(&mut app, 0, 1.0).is_empty(), "off by default"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Exposure; + let info2 = at(&mut app, 1, 2.0); + assert_eq!(info2.first(), Some(&name(1)), "Info 2 shows when the photo changes: {info2:?}"); + assert!(!at(&mut app, 1, 2.0 + BRIEF_INFO_SECS - 0.5).is_empty(), "still up"); + assert!(at(&mut app, 1, 2.0 + BRIEF_INFO_SECS + 0.1).is_empty(), "then hidden"); + assert_eq!(at(&mut app, 0, 10.0).first(), Some(&name(0)), "and shown again for the next photo"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Basic; + let info1 = at(&mut app, 1, 20.0); + assert_eq!(info1.len(), 3, "Info 1's three lines: {info1:?}"); + assert_ne!(info1, info2, "Info 1, not Info 2"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(at(&mut app, 1, 30.0), info2, "an overlay that is on shows its own Info, and stays"); + } + /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index 1bda17758..11290b1b1 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -80,7 +80,8 @@ fn menu_text(sc: &str, mac: bool) -> String { } fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) { - let changed = app.ui.settings.keymap.contains_key(b.id); + // a declared key that yields to another command's saved key counts as changed: ↺ takes it back + let changed = app.ui.settings.keymap.contains_key(b.id) || (sc.is_none() && b.default.is_some()); let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text); if changed { name = name.strong(); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a29534be3..3cb5f00f7 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -20,6 +20,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let Some(id) = app.session.active() else { // 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; 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") { @@ -674,8 +676,17 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); divider(ui); - let m = p.meta.clone(); + // with several photos selected: Target Photo shows this photo's values, Selected Photos what + // the selection shares (a field where they differ is empty and says Mixed) + let selected = app.session.selected(); + let several = selected.len() > 1; + let for_selected = several && app.ui.settings.info_selected_photos; + let m = shown_meta(&app.session.catalog, &p, &selected, for_selected); + let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; padded(ui, |ui| { + if several { + metadata_for(app, ui); + } for (label, key, value, lines) in [ ("Title", "title", &m.title, 1), ("Caption", "caption", &m.caption, 2), @@ -683,15 +694,15 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Extended Description", "extendedDescription", &m.extended_description, 3), ("Copyright", "copyright", &m.copyright, 1), ] { - meta_field(app, ui, label, key, value, lines); + meta_field(app, ui, label, key, value, lines, mixed.contains(&key)); } - copyright_status(app, ui, m.copyright_status); + copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus")); for (label, key, value, lines) in [ ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), ("Creator", "creator", &m.creator, 1), ] { - meta_field(app, ui, label, key, value, lines); + meta_field(app, ui, label, key, value, lines, mixed.contains(&key)); } // file: name (rename), path (reveal), capture time (edit) let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); @@ -740,28 +751,32 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("State / Province", "state", &m.state), ("Country", "country", &m.country), ] { - meta_field(app, ui, label, key, value, 1); + meta_field(app, ui, label, key, value, 1, mixed.contains(&key)); } // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it - let gps = m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default(); + let gps_mixed = mixed.contains(&"gps"); + let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; let gid = egui::Id::new("info-gps"); ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); - let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY)); - crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); - register(ui.ctx(), "field:gps", r.rect); - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(gid, text.clone())); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &selected)); + let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); + let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; + if r.editing { + ui.data_mut(|d| d.insert_temp(gid, edit.clone())); } else { - ui.data_mut(|d| d.remove::(gid)); + ui.data_mut(|d| d.remove::(gid)); } - if r.lost_focus() - && text.trim() != gps - && let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()})) + // Return or leaving saves; Esc gives the edit up + let text = edit.text.trim(); + if r.committed() + && (text != edit.start || edit.clears()) + && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) { app.toast(ui.ctx(), e); } - if let Some((la, lo)) = m.gps { + // (one photo's place: not shown while the selected photos' places differ) + if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) { let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); ui.horizontal(|ui| { ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); @@ -896,15 +911,136 @@ fn human_size(bytes: u64) -> String { } } -/// A labelled metadata text field: the typed text lives in egui memory while focused and is -/// saved (photo.setMeta `key`) when the field loses focus. -/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). -fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { +/// What a metadata field shows while the selected photos' values differ (Selected Photos). +const MIXED: &str = "Mixed"; + +/// An Info panel field's edit under way, kept in egui memory between frames. A field saves when +/// its text is not the text the edit started from, so a field left as it was saves nothing, even +/// when what the panel shows changed during the edit. It saves to the photos selected when the +/// edit started, also when the selection changed since. +#[derive(Clone)] +struct FieldEdit { + text: String, + start: String, + /// The selected photos when the edit started (`photo.setMeta` `ids`). + ids: Vec, + /// The field said Mixed when the edit started. + mixed: bool, + /// Something was typed or deleted since. + typed: bool, +} + +impl FieldEdit { + fn new(value: &str, mixed: bool, selected: &[PhotoId]) -> Self { + let ids = selected.iter().map(|id| id.0).collect(); + FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false } + } + + /// A Mixed field that was typed in and left empty: it clears the field on every selected photo. + fn clears(&self) -> bool { + self.mixed && self.typed + } +} + +/// The Info panel's text fields, by their `photo.setMeta` key. +const META_TEXT_KEYS: [&str; 12] = [ + "title", + "caption", + "altText", + "extendedDescription", + "copyright", + "usageTerms", + "copyrightUrl", + "creator", + "location", + "city", + "state", + "country", +]; + +/// The value of one of [`META_TEXT_KEYS`]. +fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> { + Some(match key { + "title" => &m.title, + "caption" => &m.caption, + "altText" => &m.alt_text, + "extendedDescription" => &m.extended_description, + "copyright" => &m.copyright, + "usageTerms" => &m.usage_terms, + "copyrightUrl" => &m.copyright_url, + "creator" => &m.creator, + "location" => &m.location, + "city" => &m.city, + "state" => &m.state, + "country" => &m.country, + _ => return None, + }) +} + +/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected` +/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first +/// selected photo's. +pub(crate) fn shown_meta( + catalog: &lightcraft_catalog::Catalog, + p: &lightcraft_catalog::Photo, + selected: &[PhotoId], + for_selected: bool, +) -> lightcraft_catalog::Meta { + if !for_selected || selected.contains(&p.id) { + return p.meta.clone(); + } + selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone()) +} + +/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of +/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws. +pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> { + let mut mixed = Vec::new(); + for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) { + for key in META_TEXT_KEYS { + if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) { + mixed.push(key); + } + } + if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status { + mixed.push("copyrightStatus"); + } + if !mixed.contains(&"gps") && m.gps != shown.gps { + mixed.push("gps"); + } + if mixed.len() == META_TEXT_KEYS.len() + 2 { + break; + } + } + mixed +} + +/// Target Photo / Selected Photos: whose metadata the fields below show while several photos +/// are selected (`view.infoSelectedPhotos`). +fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + let on = app.ui.settings.info_selected_photos; + for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] { + if text_button(ui, id, label, on == selected).clicked() && on != selected { + let _ = app.run("view.infoSelectedPhotos", json!({"on": selected})); + } + } + }); + ui.add_space(6.0); +} + +/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the +/// selected photos' statuses differ, and any status picked is given to all of them. +fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { + let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() }); + let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| { for st in lightcraft_catalog::CopyrightStatus::ALL { - if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { + let on = !mixed && st == current; + if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on { let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); } } @@ -913,27 +1049,34 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr ui.add_space(6.0); } -fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { +/// A labeled metadata text field: the typed text lives in egui memory while focused and is +/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values +/// differ, so the field starts empty and says so, and is saved only when it was typed in (left +/// empty, it clears the field on every selected photo). +fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::new(("info-field", key)); - let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string()); + let value = if mixed { "" } else { value }; + let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, &app.session.selected())); let widget = format!("field:{key}"); let field = if lines > 1 { - crate::text_field::TextField::multiline(&widget, &mut text).rows(lines) + crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) } else { - crate::text_field::TextField::singleline(&widget, &mut text) + crate::text_field::TextField::singleline(&widget, &mut edit.text) }; + let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field }; let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); + edit.typed |= edit.text != edit.start; // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) if r.editing { - ui.data_mut(|d| d.insert_temp(id, text.clone())); + ui.data_mut(|d| d.insert_temp(id, edit.clone())); } else { - ui.data_mut(|d| d.remove::(id)); + ui.data_mut(|d| d.remove::(id)); } // Return (one line) or leaving saves; Esc gives the edit up - if r.committed() && text != value { - let _ = app.run("photo.setMeta", json!({key: text})); + if r.committed() && (edit.text != edit.start || edit.clears()) { + let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text})); } ui.add_space(6.0); } @@ -1014,11 +1157,16 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { // switched away, doesn't claim the cursor whenever it is next drawn) let bid = egui::Id::new("kw-begin"); let begin = ui.data_mut(|d| d.remove_temp::(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 r = crate::text_field::TextField::singleline("field:keyword", &mut text) .hint(crate::i18n::tr("Add keyword")) .width(f32::INFINITY) - .begin_edit(begin) + .begin_edit(begin || asked) .show(ui); + if asked { + r.response.scroll_to_me(None); + } let in_sight = ui.clip_rect().contains(r.response.rect.center()); let mut drawn = Rect::NOTHING; // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ef9ebf85d..d93aa0688 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -870,8 +870,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } /// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and -/// which, and the three lines each of Info 1 and Info 2 shows (`view.viewOptions`). Changes apply -/// as they are made. +/// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the +/// photo changes, and whether the loupe shows its loading and rendering message +/// (`view.viewOptions`). Changes apply as they are made. pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; heading(ui, t, crate::i18n::tr("Loupe View")); @@ -906,7 +907,15 @@ pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Token if r.clicked() { *lines = defaults; } + // one Info at most shows briefly: ticking one unticks the other + let info = if n == 1 { I::Basic } else { I::Exposure }; + let mut briefly = app.ui.settings.loupe_info.briefly == info; + if check(ui, &format!("viewOptions.briefly{n}"), &mut briefly, "Show briefly when photo changes") { + app.ui.settings.loupe_info.briefly = if briefly { info } else { I::Off }; + } } + heading(ui, t, crate::i18n::tr("General")); + check(ui, "viewOptions.loupeMessages", &mut app.ui.settings.loupe_messages, "Show message when loading or rendering photos"); } // ----------------------------------------------------------------------------------- Slideshow @@ -972,6 +981,11 @@ fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; heading(ui, t, crate::i18n::tr("Playback")); let s = app.session.slideshow.clone(); + let mut v = s.manual; + if check(ui, "settings.slideshowManual", &mut v, "Manual slideshow") { + let _ = app.run("slideshow.playback", json!({"manual": v})); + } + hint(ui, t, crate::i18n::tr("A manual slideshow shows each slide until → or ← changes it. Exports still use the slide duration.")); row(ui, t, crate::i18n::tr("Slide duration"), |ui| { duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) }); @@ -1343,6 +1357,7 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result // or failed switch reopens the same catalog, so its pins stay). app.ui.reference = None; app.ui.compare = None; + app.ui.brief_info = crate::state::BriefInfo::default(); if app.ui.view == crate::state::ViewMode::Reference { app.ui.view = crate::state::ViewMode::PhotoGrid; } diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index a0f7ba09f..8e74f26bc 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -1,6 +1,6 @@ //! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the //! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour, -//! random order, repeat, preparing previews in advance). +//! random order, repeat, preparing previews in advance, manual slideshow). //! //! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this //! module renders the slides, draws the frame it asks for and the playback controls. @@ -53,7 +53,7 @@ pub struct Run { } /// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?, -/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's +/// repeat?, preparePreviews?, manual?}`: play the photos in view from the selected one with the library's /// playback settings; the keys given change them for this run only (`interval` = slideDuration, /// `seed` fixes the random order). The intro and ending screens that are on (`slideshow.titles`) /// play before the first photo and after the last one. With no photos in view there is nothing @@ -127,7 +127,9 @@ pub fn state(play: &Playback, now: f64) -> Value { "fading": f.from.is_some(), "progress": f.progress, "waitingFor": play.waiting_for().map(|i| i.photo().map_or_else(|| json!(i.title().map(Title::id)), |p| json!(p.0))), + // (null in a manual slideshow: no slide is ever due) "remaining": play.remaining(now), + "manual": play.settings().manual, }) } @@ -174,7 +176,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let f = run.play.frame(now); if f.from.is_some() { ctx.request_repaint(); // a fade draws every frame - } else if !run.play.is_paused() { + } else if !run.play.is_paused() && !run.play.settings().manual { ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0))); } if run.controls_until > now { diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index d1c94f142..412a8167a 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -212,6 +212,14 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); } + if app.services.pick_print_templates.is_some() && text_button(ui, "printTemplateImport", "Import…", false).clicked() { + let paths = app.services.pick_print_templates.as_mut().map(|pick| pick()).unwrap_or_default(); + for path in paths { + if let Some(r) = run(app, &ctx, "print.template.import", json!({"path": path})) { + app.toast(&ctx, crate::i18n::tr_format!("Imported {name}", name = r["name"].as_str().unwrap_or_default())); + } + } + } }); let names: Vec = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); for (i, name) in names.iter().enumerate() { @@ -221,6 +229,15 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { run(app, &ctx, "print.template.delete", json!({"name": name})); } + if app.services.save_print_template.is_some() && text_button(ui, &format!("printTemplateExport-{i}"), "Export…", false).clicked() { + let stem = lightcraft_engine::slide_template::file_stem(name).unwrap_or_else(|| "Print Template".into()); + let file = format!("{stem}.{}", lightcraft_engine::print::template::FILE_EXT); + if let Some(path) = app.services.save_print_template.as_mut().and_then(|save| save(&file)) + && let Some(r) = run(app, &ctx, "print.template.export", json!({"name": name, "path": path})) + { + app.toast(&ctx, crate::i18n::tr_format!("Exported to {path}", path = r["path"].as_str().unwrap_or_default())); + } + } if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { run(app, &ctx, "print.template.apply", json!({"name": name})); pu.page = 0; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 032548f6a..32fc6bea4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -54,15 +54,30 @@ pub fn find_bindable(id: &str) -> Option<&'static Bindable> { /// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry /// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is -/// ignored. +/// ignored. The declared shortcut yields to a saved entry that gives its key to another known +/// command other than its contextual partner. pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> { match keymap.get(id) { Some(sc) if sc.is_empty() => None, Some(sc) if parse(sc).is_some() => Some(sc.as_str()), - _ => default, + _ => default.filter(|sc| !assigned_elsewhere(keymap, id, sc)), } } +/// Whether the keymap gives the key `sc` to a known command other than `id` and its contextual +/// partner. +fn assigned_elsewhere(keymap: &Keymap, id: &str, sc: &str) -> bool { + let Some(key) = parse(sc) else { return false }; + saved_keys(keymap) + .any(|(other, k)| other != id && k == key && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == other) || (*y == id && *x == other))) +} + +/// 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 { + keymap.iter().filter(|(id, _)| find_bindable(id).is_some()).filter_map(|(id, sc)| Some((id.as_str(), parse(sc)?))) +} + /// The effective shortcut of command `id` (`None` for unknown commands). pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> { find_bindable(id).and_then(|b| binding(keymap, id, b.default)) @@ -373,8 +388,8 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // The grid navigation keys act on the photos in view, so they wait while the full-screen preview // covers the grid: a key must not move the selection onto photos nobody can see. let grid_navigation = grid && !app.ui.fullscreen; - // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them - let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); + // keys the user gave to a known command: the fixed bindings below (aliases, ratings) yield to them + let taken: Vec<(Modifiers, Key)> = saved_keys(keymap).map(|(_, k)| k).collect(); // an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back, // which also closes dialogs) waits until nothing is open let popup_open = egui::Popup::is_any_open(ctx); @@ -828,6 +843,50 @@ mod tests { } } + /// A saved keymap entry that gives a key to one command (kept from before another command + /// declared that key) wins: the press runs that command alone, and the other command's declared + /// key comes back once the entry is gone. + #[test] + fn a_saved_key_wins_over_another_commands_declared_key() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None); + assert_eq!(super::conflicts(&h.app.ui.settings.keymap, "view.survey", "P"), vec!["photo.increaseRating"]); + key(&mut h, "P", false); + let photo = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(photo.rating, 1, "the saved key raised the rating"); + assert_eq!(photo.flag, Flag::None, "Flag as Pick's declared key yielded"); + // with the entry gone, P picks again + h.app.ui.settings.keymap.remove("photo.increaseRating"); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P")); + key(&mut h, "P", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick); + // a contextual partner's saved key does not silence the other side + h.app.ui.settings.keymap.insert("crop.rotateAspect".into(), "X".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.reject"), Some("X")); + // nor does an entry for a command this build doesn't know + h.app.ui.settings.keymap.insert("no.suchCommand".into(), "U".into()); + assert_eq!(super::shortcut_of(&h.app.ui.settings.keymap, "photo.unflag"), Some("U")); + } + + /// Keymap entries for commands this build doesn't know take no key: an alias, a rating key and + /// a declared shortcut on the same keys keep working. + #[test] + fn junk_keymap_entries_leave_aliases_ratings_and_declared_keys() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + for (id, sc) in [("no.suchCommand", "Shift+X"), ("no.otherCommand", "5"), ("no.thirdCommand", "P")] { + h.app.ui.settings.keymap.insert(id.into(), sc.into()); + } + key(&mut h, "5", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 5, "5 still rates"); + key(&mut h, "P", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Pick, "P still picks"); + key(&mut h, "X", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject, "Shift+X still rejects"); + } + #[test] fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { let mut h = library(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 53d5b8718..decdf6364 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -259,12 +259,31 @@ impl LoupeInfoField { } /// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and -/// Info 2 ([`InfoOverlay::Exposure`]). +/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LoupeInfo { pub info1: [LoupeInfoField; 3], pub info2: [LoupeInfoField; 3], + /// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for + /// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]: + /// neither). + pub briefly: InfoOverlay, +} + +/// How long Show Briefly When Photo Changes shows the info overlay (seconds). +pub const BRIEF_INFO_SECS: f64 = 3.0; + +/// When the loupe last changed photo, for Show Briefly When Photo Changes +/// ([`LoupeInfo::briefly`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BriefInfo { + /// The photo the loupe showed last. + pub photo: Option, + /// When the loupe changed to it (seconds on egui's clock). + pub since: f64, + /// The brief info overlay shows this frame. + pub shown: bool, } impl LoupeInfo { @@ -276,7 +295,7 @@ impl LoupeInfo { impl Default for LoupeInfo { fn default() -> Self { - LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2 } + LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off } } } @@ -458,12 +477,19 @@ pub struct AppSettings { pub photocraft_file: EditFile, /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). pub memory_mb: u32, + /// The Info panel's metadata fields with several photos selected: true (the default) shows the + /// values the selected photos share and marks each field where they differ (Selected Photos), + /// false shows the active photo's values (Target Photo) (`view.infoSelectedPhotos`). + pub info_selected_photos: bool, /// Filmstrip: file names above the thumbnails. pub film_names: bool, /// Filmstrip: rating / flag / edited badges on the thumbnails. pub film_badges: bool, /// Grid: when to show the rating / flag / edited badges. pub grid_badges: GridBadges, + /// View ▸ View Options ▸ Show message when loading or rendering photos: the loupe says + /// "Rendering…" while the photo it shows is not the finished render. + pub loupe_messages: bool, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, /// Auto (follow the system), Dark or Light. @@ -526,9 +552,11 @@ impl Default for AppSettings { photocraft_path: String::new(), photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, + info_selected_photos: true, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, + loupe_messages: true, keymap: Default::default(), appearance_mode: AppearanceMode::Dark, dark_theme: DarkTheme::Charcoal, @@ -900,6 +928,9 @@ 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). + #[serde(skip)] + pub focus_keywords: bool, /// A mask being renamed in the Masks list: its id and the edited name. #[serde(skip)] pub renaming_mask: Option<(u32, String)>, @@ -1022,6 +1053,9 @@ pub struct UiState { pub slideshow: Option>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, + /// When the loupe last changed photo (View Options' Show Briefly When Photo Changes). + #[serde(skip)] + pub brief_info: BriefInfo, /// The loupe's layout image overlay (View ▸ Loupe Overlay). pub layout_image: LayoutImage, /// Navigator mini map in the loupe while zoomed in. @@ -1508,6 +1542,7 @@ impl Default for UiState { known_cards: None, search: String::new(), focus_search: false, + focus_keywords: false, renaming_mask: None, describe: None, detail_due: None, @@ -1555,6 +1590,7 @@ impl Default for UiState { keyword_list_selected: None, dragging_keyword: None, info_overlay: InfoOverlay::Off, + brief_info: BriefInfo::default(), layout_image: LayoutImage::default(), navigator: true, settings: AppSettings::default(), @@ -1702,16 +1738,36 @@ mod loupe_info_tests { fn view_options_lines_are_saved_with_the_settings() { let mut settings = AppSettings::default(); settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; + settings.loupe_info.briefly = InfoOverlay::Exposure; let saved = serde_json::to_value(&settings).unwrap(); assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); + assert_eq!(saved["loupeInfo"]["briefly"], "exposure"); let read: AppSettings = serde_json::from_value(saved).unwrap(); assert_eq!(read.loupe_info, settings.loupe_info); let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); assert_eq!(old.loupe_info, LoupeInfo::default()); + // lines saved before Show Briefly When Photo Changes existed are kept, with it off + let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap(); + assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2); + assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off); let newer: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); assert_eq!(newer.loupe_info, LoupeInfo::default()); assert!(!newer.film_names, "the rest of the settings are still read"); } + + /// Show message when loading or rendering photos is on until it is turned off, and is saved + /// with the app's settings. + #[test] + fn the_loading_message_choice_is_saved_with_the_settings() { + assert!(AppSettings::default().loupe_messages); + let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); + assert!(old.loupe_messages, "a settings file from before the choice existed keeps the message"); + let settings = AppSettings { loupe_messages: false, ..AppSettings::default() }; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeMessages"], false); + let read: AppSettings = serde_json::from_value(saved).unwrap(); + assert!(!read.loupe_messages); + } } diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index b48da7cdf..6e784de1a 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -103,6 +103,27 @@ fn a_key_given_to_a_command_overrides_a_fixed_key() { assert_eq!(before, after, "the rating key must not fire too"); } +/// A declared key that yields to another command's saved key can be taken back with ↺. +#[test] +fn restore_takes_back_a_declared_key_another_command_holds() { + use crate::shortcuts::shortcut_of; + let mut h = demo(); + h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into()); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + click(&mut h, "check:shortcuts.showAll"); + click(&mut h, "field:shortcutsSearch"); + assert_eq!(h.request("ui.text", json!({"text": "flag as pick"}), T)["ok"], true); + h.step(); + h.step(); + click(&mut h, "button:shortcutReset-photo.pick"); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P")); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), None); + assert!(h.app.ui.settings.keymap.is_empty(), "{:?}", h.app.ui.settings.keymap); +} + /// Open the editor, filtered to `search`, and start recording for `id`. fn record(h: &mut Headless, search: &str, id: &str) { if h.app.ui.dialog.is_none() { @@ -176,6 +197,51 @@ fn shift_insert_pastes_plainly() { assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings"); assert_eq!(h.app.session.undo.len(), undo + 1, "the settings were pasted"); } + +/// ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V) copy and paste metadata, as in Lightroom Classic's Library, +/// both as key presses and as the clipboard events Windows and Linux turn them into; the edit +/// settings clipboard is left alone. +#[test] +fn cmd_alt_shift_c_and_v_copy_and_paste_metadata() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.visible().iter().copied().take(2).collect(); + let (a, b) = (ids[0], ids[1]); + let title = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.title.clone(); + h.app.run("library.select", json!({"ids": [a.0]})).unwrap(); + h.app.run("photo.setMeta", json!({"title": "Dawn", "creator": "Me"})).unwrap(); + h.app.run("library.select", json!({"ids": [b.0]})).unwrap(); + h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap(); + let chord = json!({"cmd": true, "alt": true, "shift": true}); + let press = |h: &mut Headless, k: &str| { + let mut p = chord.clone(); + p["key"] = json!(k); + let r = h.request("ui.key", p, T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + }; + + h.app.run("library.select", json!({"ids": [a.0]})).unwrap(); + press(&mut h, "C"); + assert_eq!(h.app.session.meta_clipboard.as_ref().map(|m| m["title"].clone()), Some(json!("Dawn")), "the key copied the metadata"); + h.app.run("library.select", json!({"ids": [b.0]})).unwrap(); + press(&mut h, "V"); + assert_eq!(title(&h, b), "Dawn", "the key pasted the metadata"); + assert_eq!(h.app.session.catalog.photo(b).unwrap().meta.creator, "Me"); + + h.app.session.meta_clipboard = None; + h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap(); + let all = egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT; + h.app.run("library.select", json!({"ids": [a.0]})).unwrap(); + h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Copy]); + h.settle(SETTLE); + assert!(h.app.session.meta_clipboard.is_some(), "the Copy event with ⌥⇧ held copied the metadata"); + h.app.run("library.select", json!({"ids": [b.0]})).unwrap(); + h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Paste("text".into())]); + h.settle(SETTLE); + assert_eq!(title(&h, b), "Dawn", "the Paste event with ⌥⇧ held pasted the metadata"); + assert!(h.app.session.clipboard.is_none(), "no edit settings were copied"); + assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings"); +} /// The opening release can carry both physical shortcut keys and the replacement text. #[test] fn opening_numeric_release_keeps_keys_in_the_editor() { diff --git a/crates/ui-egui/src/tests_keyword_set.rs b/crates/ui-egui/src/tests_keyword_set.rs index 4667f5367..4874a58a9 100644 --- a/crates/ui-egui/src/tests_keyword_set.rs +++ b/crates/ui-egui/src/tests_keyword_set.rs @@ -160,3 +160,20 @@ fn save_as_a_new_set_keeps_the_original() { assert_eq!(names, ["Recent Keywords", "Weddings", "Ceremonies"]); assert_eq!(s["current"], "Ceremonies"); } + +/// ⌥0 / ⌥⇧0 (Alt+0 / Alt+Shift+0) go to the next and the previous keyword set, as in Lightroom +/// Classic's Library, and ⌥1 then applies the new set's first keyword. +#[test] +fn alt_0_and_alt_shift_0_cycle_the_keyword_sets() { + let mut h = keywords_panel(); + h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + h.app.session.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + h.settle(SETTLE); + ask(&mut h, "ui.key", json!({"key": "0", "alt": true})); + assert_eq!(sets(&mut h)["current"], "Weddings", "⌥0: the next set"); + ask(&mut h, "ui.key", json!({"key": "1", "alt": true})); + let id = h.app.session.selected()[0]; + assert!(h.app.session.catalog.photo(id).unwrap().meta.keywords.iter().any(|k| k == "ceremony"), "⌥1 applied the set's keyword"); + ask(&mut h, "ui.key", json!({"key": "0", "alt": true, "shift": true})); + assert_eq!(sets(&mut h)["current"], "Recent Keywords", "⌥⇧0: the previous set"); +} diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 076f25789..6148938e0 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -108,6 +108,42 @@ fn the_add_keyword_box_pastes_adds_and_gives_up() { assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"harbour".to_string())), "Esc gave it up"); } +/// ⌘K (Ctrl+K) is Add Keywords: it shows the Keywords panel, opens Keywording when it is folded +/// shut, and puts the cursor in the Add keyword box, so what is typed next goes to the selection. +#[test] +fn cmd_k_puts_the_cursor_in_the_add_keyword_box() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywording"})); + ask(&mut h, "ui.set", json!({"right": "none"})); + ask(&mut h, "ui.key", json!({"key": "K", "cmd": true})); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Keywords, "the Keywords panel"); + assert!(!h.app.ui.section_folded("keywording") && has(&h, "field:keyword"), "Keywording is open"); + assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box has the cursor"); + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"harbour".to_string())), "typed and given to both"); + assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "Return gave up the cursor"); + ask(&mut h, "ui.key", json!({"key": "K", "cmd": true})); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Keywords, "pressed again: the panel stays"); + assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "and the cursor is back in the box"); +} + +/// With no photo, ⌘K shows the Keywords panel and its note; selecting photos afterwards doesn't put the +/// cursor in the Add keyword box. +#[test] +fn cmd_k_without_a_photo_leaves_the_cursor_alone() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": []}})); + assert!(h.app.session.active().is_none(), "no active photo"); + ask(&mut h, "ui.set", json!({"right": "none"})); + ask(&mut h, "ui.key", json!({"key": "K", "cmd": true})); + assert_eq!(h.app.ui.right, crate::state::RightPanel::Keywords, "the Keywords panel"); + assert!(!has(&h, "field:keyword"), "no box without a photo"); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": ids}})); + assert!(has(&h, "field:keyword")); + 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] diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests_launch_library.rs index b0b3bec6b..61c894750 100644 --- a/crates/ui-egui/src/tests_launch_library.rs +++ b/crates/ui-egui/src/tests_launch_library.rs @@ -129,8 +129,8 @@ fn the_list_checks_each_library_and_selects_the_last_one() { let _ = std::fs::remove_dir_all(&dir); } -/// Every file under `dir`: relative path → (contents, modification time). -fn snapshot(dir: &Path) -> std::collections::BTreeMap, std::time::SystemTime)> { +/// Every file under `dir`: relative path → (contents, length, modification time). +fn snapshot(dir: &Path) -> std::collections::BTreeMap>, u64, std::time::SystemTime)> { let mut out = std::collections::BTreeMap::new(); let mut todo = vec![dir.to_path_buf()]; while let Some(d) = todo.pop() { @@ -140,13 +140,25 @@ fn snapshot(dir: &Path) -> std::collections::BTreeMap, std::ti if m.is_dir() { todo.push(p.clone()); } - let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() }; - out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap())); + let (bytes, len) = if m.is_file() { (contents(&p), m.len()) } else { (Some(Vec::new()), 0) }; + out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, len, m.modified().unwrap())); } } out } +/// The bytes of the file `p`, or `None` for a lock file that a [`lightcraft_catalog::LibraryLock`] +/// holds on Windows: there `LockFileEx` locks the file's bytes, and reading them through another +/// handle fails with os error 33 (`ERROR_LOCK_VIOLATION`). Such a file is compared by its length +/// and modification time only. +fn contents(p: &Path) -> Option> { + match std::fs::read(p) { + Ok(bytes) => Some(bytes), + Err(e) if cfg!(windows) && e.raw_os_error() == Some(33) && p.file_name().is_some_and(|n| n == lightcraft_catalog::lock::LOCK) => None, + Err(e) => panic!("{}: {e}", p.display()), + } +} + /// Listing the recent libraries (and drawing the window) writes nothing into any of them: no /// lock file, no owner file, nothing modified — free, held by another program or not a library. #[test] diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 28838f409..25f7014ee 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -214,6 +214,50 @@ fn templates_save_apply_and_delete_from_the_dialog() { assert!(h.app.session.print_templates.is_empty()); } +#[test] +fn templates_export_and_import_from_the_dialog() { + let dir = std::env::temp_dir().join(format!("lightcraft-ui-print-templates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Sheet 5x6.lcprint").to_string_lossy().to_string(); + let suggested = Arc::new(Mutex::new(Vec::::new())); + let names = suggested.clone(); + let (save_to, pick) = (file.clone(), file.clone()); + let services = Services { + save_print_template: Some(Box::new(move |name: &str| { + names.lock().unwrap().push(name.to_string()); + Some(save_to.clone()) + })), + pick_print_templates: Some(Box::new(move || vec![pick.clone()])), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + click(&mut h, "button:printTemplateExport-0"); + assert_eq!(*suggested.lock().unwrap(), vec!["Sheet 5x6.lcprint".to_string()]); + assert!(std::path::Path::new(&file).is_file()); + click(&mut h, "button:printTemplateImport"); + let names: Vec<&str> = h.app.session.print_templates.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["Sheet 5x6", "Sheet 5x6 (2)"]); + assert_eq!(h.app.session.print_templates[1].settings, h.app.session.print_templates[0].settings); + assert!(has(&h, "button:printTemplateExport-1"), "the imported template is listed"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn template_files_need_the_hosts_file_dialogs() { + let mut h = demo(Services::default()); + open_print(&mut h); + h.app.ui.print.template_name = "Plain".into(); + click(&mut h, "button:printSaveTemplate"); + assert!(has(&h, "button:printTemplateApply-0")); + assert!(!has(&h, "button:printTemplateExport-0"), "no save dialog: no Export…"); + assert!(!has(&h, "button:printTemplateImport"), "no open dialog: no Import…"); +} + #[test] fn print_to_pdf_picks_a_file_and_runs_in_the_background() { let asked = Arc::new(Mutex::new(Vec::::new())); diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index f4f54c3ad..95a148c98 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -154,6 +154,46 @@ fn arrows_cut_and_repeat_off_ends_after_the_last_slide() { h.settle(SETTLE); } +#[test] +fn a_manual_slideshow_waits_for_the_arrow_keys() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + // the library setting, as the Settings checkbox sets it + run(&mut h, "slideshow.playback", json!({"manual": true})); + let r = run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + assert_eq!(r["settings"]["manual"], true, "{r}"); + // once the first two slides are rendered, and far longer than the slide duration: the first + // photo stays up + assert!(h.step_until(SETTLE, |h| has_slide(h, photos[0]) && has_slide(h, photos[1])), "the slides are rendered"); + for _ in 0..120 { + h.step(); + } + assert_eq!(photo_up(&h), photos[0], "nothing advances on its own"); + let r = h.request("ui.key", json!({"key": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(photo_up(&h), photos[1]); + assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows"); + let s = run(&mut h, "slideshow.togglePause", json!({})); + assert_eq!((s["manual"].clone(), s["remaining"].clone()), (json!(true), json!(null)), "{s}"); + run(&mut h, "slideshow.end", json!({})); + assert!(h.app.ui.slideshow.is_none()); + // off again: it advances by itself + run(&mut h, "slideshow.playback", json!({"manual": false})); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + let up = photo_up(&h); + assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.current_photo() != Some(up)))); + run(&mut h, "slideshow.end", json!({})); + // the Settings checkbox turns it on + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + reveal(&mut h, "check:settings.slideshowManual"); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowManual"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slideshow.manual, "the checkbox goes through slideshow.playback"); + h.settle(SETTLE); +} + #[test] fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() { let order = |seed: u64| { diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 3e1ce6e37..e2e4f4d11 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -75,6 +75,18 @@ fn switching_libraries_dismisses_the_previous_lightroom_result() { let _ = std::fs::remove_dir_all(dir); } +/// Photo IDs are local to a library: the brief info overlay's last photo is forgotten, so a photo +/// in the new library with the same ID shows the info again. +#[test] +fn switching_libraries_restarts_the_brief_info_overlay() { + let dir = temp("switch-brief-info"); + let mut app = app_in(&dir); + app.ui.brief_info = crate::state::BriefInfo { photo: Some(lightcraft_catalog::PhotoId(1)), since: 5.0, shown: true }; + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Second"})).unwrap(); + assert_eq!(app.ui.brief_info, crate::state::BriefInfo::default()); + let _ = std::fs::remove_dir_all(dir); +} + fn recent_submenu(app: &LightcraftApp) -> Vec { use crate::menubar::MenuNode; let bar = crate::menubar::menu_bar(app); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..68b5a97d9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -135,6 +135,12 @@ x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls `focus.pump {pace}` every frame to drive it. +With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or +`on: null`, toggles; any other value that is not true or false is refused; replies `{on}`; +`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values, 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. + The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. diff --git a/docs/gaps.md b/docs/gaps.md index ceea3724f..42d9d437b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -43,8 +43,8 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | -| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), ⌘⌥⇧C / ⌘⌥⇧V (Copy / Paste Metadata), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target. The Info panel's Show Metadata For choice (Target Photo / Selected Photos) shows the values the selected photos share and marks each field where they differ as Mixed; the All Metadata dialog still shows one photo's values with no "mixed" indication | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 1–3 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -84,6 +84,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | +| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | | 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | | 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 81bc2cede..98ae0558a 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -17,7 +17,10 @@ Select photos in Photo Grid or Square Grid, then use: | `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | | `\` (backslash) | Show / hide the Filter Bar | +| ⌘K / Ctrl+K | Add Keywords: show the Keywords panel with the cursor in its Add keyword box | | ⌘L / Ctrl+L | Enable Filters: switch the filter off and on | +| ⌘⌥⇧C / Ctrl+Alt+Shift+C | Copy Metadata: copy the active photo's metadata | +| ⌘⌥⇧V / Ctrl+Alt+Shift+V | Paste Metadata: paste the copied metadata on the selected photos | Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. @@ -83,7 +86,18 @@ Survey and Reference, and leave text fields alone, so the search box still types commands (`view.thumbSizeUp`, `view.thumbSizeDown`, reachable from the control channel and MCP, which return `{"size"}`); the size is saved with the other interface settings. -Public behaviour references, checked 2026-10-08 (thumbnail size: 2026-10-13): +## Keyword sets + +`⌥1`–`⌥9` (`Alt+1`–`Alt+9` on Windows and Linux) add or remove the current keyword set's keywords on +the selected photos. `⌥0` makes the next keyword set current and `⌥⇧0` the previous one (`Alt+0` / +`Alt+Shift+0`), in the order of the Keywords panel's Keyword Set menu: Recent Keywords, then the saved +sets. After the last set comes Recent Keywords again, and before Recent Keywords comes the last set; +with no saved sets the keys leave Recent Keywords current. The shortcut list does not say what happens +past either end; wrapping around is LightCraft's choice. The same steps are the Next / Previous +Keyword Set commands (`keyword.nextSet`, `keyword.previousSet`), which return what `keyword.sets` +returns, and both keys can be reassigned in Help → Keyboard Shortcuts. + +Public behavior references, checked 2026-10-08 (thumbnail size: 2026-10-13; keyword sets: 2026-10-24): - [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html) - [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d059494ce..dcdaec565 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -164,7 +164,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks 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-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 | @@ -437,7 +437,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | -| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | +| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; a declared key that `ui.json` already gives another command stays with that command until ↺ takes it back; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | | LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | | | LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | | | LR-PREF-DEVICE | Device settings | P2 | ⬜ | | | @@ -711,7 +711,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields. In the Keyword List a keyword's empty box carries a dot when the selected photos have a keyword inside it (its tooltip names them), so a folded keyword shows that something in it is applied | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:keyword.nextSet`, `cmd:keyword.previousSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9; ⌥0 / ⌥⇧0 go to the next / previous set and wrap around at either end); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields. In the Keyword List a keyword's empty box carries a dot when the selected photos have a keyword inside it (its tooltip names them), so a folded keyword shows that something in it is applied | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people @@ -730,7 +730,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | | | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | -| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; applied as they change, saved with the app settings; `view.viewOptions {info1, info2}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", the loading-message and video-frame options | +| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; Show Briefly When Photo Changes under each Info (one at most): with the overlay off, that Info shows for 3 s each time the loupe changes photo; General's Show message when loading or rendering photos (on by default; off, the loupe does not say "Rendering…"); applied as they change, saved with the app settings; `view.viewOptions {info1, info2, briefly, loupeMessages}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), the video-frame option | | LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-DEV-HISTOGRAM-DRAG | Drag on the histogram to adjust tone regions | P2 | ✅ | `crates/ui-egui/src/panels/histogram.rs`, `crates/ui-egui/src/panels/edit.rs`, `crates/develop/src/controls.rs`, `crates/ui-egui/src/tests_histogram_drag.rs`, `cmd:develop.set`, `cmd:develop.beginInteraction`, `cmd:develop.endInteraction`, `ctl:light.blacks`, `ctl:light.shadows`, `ctl:light.exposure`, `ctl:light.highlights`, `ctl:light.whites` | five zones across the plot's width, each moving the Basic control it names: Blacks 0.00–0.15, Shadows 0.15–0.35, Exposure 0.35–0.65, Highlights 0.65–0.85, Whites 0.85–1.00; every step is a `develop.set` between `develop.beginInteraction` and `develop.endInteraction` (the sliders' own path), so the draft render follows the drag live and the whole drag lands as one undo step; the zone the press landed in stays locked for the drag, so dragging across the others changes nothing; sideways travel of the plot's width sweeps that control's whole range (Shift a tenth, as on the sliders), clamped to its spec; hovering a zone tints it and shows its value in the strip under the plot ("Exposure: +0.45 EV"), with the sideways-resize cursor; a double-click resets that zone's control to its default in one undo step; the strip under the plot (readout / EXIF) and the clipping buttons in the plot's top corners are not zones | @@ -757,7 +757,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held), manual slideshow (`manual`: a slide, the intro or the ending stays up until ←/→ or the control bar changes it; exports keep the slide duration). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | | LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | @@ -766,7 +766,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | -| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `cmd:print.template.export`, `cmd:print.template.import`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; Export… writes a template to a `.lcprint` file and Import… adds one from such a file (checked like a saved template; a name already in use gets " (2)"); no built-in templates, no folders | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -782,7 +782,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys | | KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag`, `cmd:library.toggleFilters` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus; `\` shows / hides the Filter Bar in the grids (Show Original in Detail); ⌘L (Ctrl+L) Enable Filters puts the filter aside and brings it back as it was. Missing: Shift+P outside grids (Presets) | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | -| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | +| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile`, `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata`, `cmd:panel.addKeywords`, `cmd:keyword.toggleFromSet`, `cmd:keyword.nextSet`, `cmd:keyword.previousSet`, `crates/ui-egui/src/tests_keymap.rs` (`cmd_alt_shift_c_and_v_copy_and_paste_metadata`) | ⌘S saves metadata; ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows/Linux) copy the active photo's metadata and paste it on the selection; ⌘K (Ctrl+K) Add Keywords shows the Keywords panel with the cursor in its Add keyword box; ⌥1–⌥9 toggle the current keyword set's keywords, ⌥0 / ⌥⇧0 go to the next / previous set. Missing: ⌘⇧K, ⌘⌥⇧K, ⇧K, ⌘⌥K painting, ⌘⌥⇧F Visual Search, ⇧O Edit Face name (⇧O cycles the crop overlay here, and the mask overlay color while masking), and the macOS-only ⌘: / ⌘; spelling and ⌘⌥T Character palette | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | | KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | | modules not implemented | | KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | `cmd:app.shortcuts` | ⌘/ only | @@ -799,6 +799,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | +| 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | | 2026-10-24 | patch | Integration batch 43: Integrated main 98702b5c, preserving the new fail-closed GPS metadata check for Original export. Watermark geometry/orientation and HDR JPEG size-limit fixes remain in their shared helpers; no feature-count changes. | | 2026-10-23 | patch | Export integration: Integrated main 5ca49b3e, retaining Width & Height resizing, export format/bit-depth validation and batch panic cancellation alongside the existing watermark and HDR JPEG size-limit fixes. No feature-count changes. | diff --git a/docs/slideshow.md b/docs/slideshow.md index 79cf5f04e..e0c89b031 100644 --- a/docs/slideshow.md +++ b/docs/slideshow.md @@ -7,7 +7,7 @@ next to a live preview of the selected photo's slide, and View ▸ Slideshow ( | Part | Commands | Code | |---|---|---| -| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` | +| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead, manual slideshow | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` | | Layout, options, backdrop: margins, aspect preview, zoom to fill, stroke, shadow, colour, colour wash, background photo | `slideshow.layout` | `crates/slideshow` (the composer, no UI), `crates/engine/src/slide.rs` | | Overlays: identity plate, rating stars, text with tokens, watermark | `slideshow.overlays` | `crates/engine/src/slide_overlay.rs` | | Intro and ending screens | `slideshow.titles` | `crates/engine/src/slide_title.rs` | @@ -23,7 +23,7 @@ the same pixels through the same composer. View ▸ Export Slideshow… (or Export PDF / JPEG / Video at the end of Settings ▸ Slideshow) chooses a format and size, then asks where to write. The slides are the photos in view, in view order, between the intro and ending screens that -are on; random order and repeat are playback choices and don't apply. The slide aspect is the Layout's aspect preview +are on; random order, repeat and manual slideshow are playback choices and don't apply. The slide aspect is the Layout's aspect preview (Screen exports 16:9). - **PDF** — one page per slide, its picture a JPEG at the chosen quality, the page as many points as the slide has diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 16665f3d4..b6bee0dfa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA: ⌘⌥⇧C / ⌘⌥⇧V Copy / Paste Metadata; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named; KEYC-METADATA: ⌥0 / ⌥⇧0 next / previous keyword set; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named; KEYC-METADATA: ⌘K Add Keywords; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -66,12 +66,16 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. ⌘L / Ctrl+L (Enable Filters) puts the filter aside and shows every photo, and the next press brings back the same filter, stars, flag and text together; setting or clearing a filter while it is off drops the one put aside (not documented by Adobe, our choice). +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. ⌘L / Ctrl+L (Enable Filters) puts the filter aside and shows every photo, and the next press brings back the same filter, stars, flag and text together; setting or clearing a filter while it is off drops the one put aside (not documented by Adobe, our choice). ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux, KEYC-METADATA) copy the active photo's metadata and paste it on the selected photos. **Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). +**Add Keywords key (KEYC-METADATA):** `⌘K` (`Ctrl+K` on Windows and Linux) is Add Keywords (`panel.addKeywords`): it shows the Keywords panel, opens its Keywording section when that is folded shut, and puts the cursor in the Add keyword box; pressed again it leaves the panel open. With no photo selected the panel says "Select photos to give them keywords." and the key does not move the cursor. The shortcut list names the key only; what the key opens is LightCraft's choice. Covered by `crates/ui-egui/src/tests_keywording.rs`. + **Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… ; View → Visualize HDR has no default key (`Shift+H` cycles the Tool Overlay, issue #224; `Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. +**Keyword sets (KEYC-METADATA):** `⌥0` / `⌥⇧0` (`Alt+0` / `Alt+Shift+0` on Windows and Linux) make the next / previous keyword set current (`keyword.nextSet`, `keyword.previousSet`), in the Keyword Set menu's order: Recent Keywords, then the saved sets. After the last set comes Recent Keywords, and before Recent Keywords comes the last set (not documented by Adobe, our choice). `⌥1`–`⌥9` then apply the new set's keywords. Both keys can be reassigned in Help → Keyboard Shortcuts. + **Still missing / broken:** - Tab / ⇧Tab (hide the side panels / all panels): Tab moves the keyboard focus here (accessibility), so it is not bound; F5 (hide the top panel) is not bound either. @@ -148,6 +152,9 @@ establish OS keyboard delivery or cross-platform acceptance. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | KEYC-METADATA: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata | +| 2026-10-24 | minor | KEYC-METADATA: ⌥0 / ⌥⇧0 (Alt+0 / Alt+Shift+0) go to the next / previous keyword set, Recent Keywords first, wrapping around at either end | +| 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (Add Keywords) shows the Keywords panel with the cursor in its Add keyword box | | 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged | | 2026-10-23 | patch | KEYC-RATING: ⌘L / Ctrl+L (Library ▸ Enable Filters) switches the filter off and on, restoring it as it was | | 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left |