From 59e9a6a4f3caa014c1ca27e22e3705d956388333 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:56:28 -0700 Subject: [PATCH 01/19] LRC-PRINT-TEMPLATES: print templates export to a file and import from one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Print dialog's templates could be saved, applied and deleted, but not moved to another library or another computer. Each saved template now has an Export… button that writes it to a .lcprint file, and Import… beside Save adds templates from such files. print.template.export {name, path} writes {format, version, name, settings}. print.template.import {path} reads at most 256 KB, checks the format, version, name and every setting as a saved template's settings are checked, drops cell photos and guides, and adds the template under its name, or under "name (2)" and so on when the name is in use. The buttons appear where the host provides open and save dialogs: the desktop app does, and the web build does not. --- apps/lightcraft-web/src/web.rs | 2 + apps/lightcraft/src/main.rs | 18 +++++ crates/engine/src/cmd/print.rs | 60 ++++++++++++++- crates/engine/src/print/template.rs | 115 ++++++++++++++++++++++++++++ crates/engine/src/tests_print.rs | 72 +++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/print_view.rs | 17 ++++ crates/ui-egui/src/tests_print.rs | 44 +++++++++++ docs/parity-checklist.md | 2 +- 9 files changed, 331 insertions(+), 3 deletions(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 14a5d050b..9bb34d614 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -224,6 +224,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 95739b3e9..f5cc971e5 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -704,6 +704,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/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 26f5d9bc4..541773273 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 { @@ -318,6 +318,56 @@ fn template_delete(s: &mut Session, p: &Value) -> Result { 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(); + s.print_templates.push(t); + s.save_prefs()?; + 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 +399,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/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/tests_print.rs b/crates/engine/src/tests_print.rs index 898faa889..ca6ea486c 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -295,6 +295,76 @@ 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 dir = std::env::temp_dir().join(format!("lightcraft-print-templates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + 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")); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_hostile_template_in_the_preferences_does_not_break_the_library() { let files = MemStore::new(); @@ -336,6 +406,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/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f23c9bd7..87a773be8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -273,6 +273,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/print_view.rs b/crates/ui-egui/src/print_view.rs index a18e1f691..2c34cffd6 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -197,6 +197,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() { @@ -206,6 +214,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/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/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..fc225468e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -765,7 +765,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 | 🚫 | | | From c453cee157403e40e590e0e8f3c971e2d1c1229f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:57:12 -0700 Subject: [PATCH 02/19] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index cc71e4cc2..b116c46d3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -67,3 +67,7 @@ sync_github_name = true [people.ronibalthazar] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From adf94a87cbb4088e335ff1cc7bdf0f345eb77a74 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:46:49 -0700 Subject: [PATCH 03/19] LRC-SS-PLAYBACK: a manual slideshow waits for the arrow keys The slideshow always advanced on its own after the slide duration. The new Manual slideshow setting (Settings > Slideshow > Playback, or manual in slideshow.playback and view.slideshow) keeps each slide, the intro and the ending up until the right or left arrow or the control bar changes it. Space, Esc, random order and repeat work as before. Exports still use the slide duration. The setting is saved with the library and in slideshow templates; a template without it reads as off. --- crates/engine/src/cmd/slideshow.rs | 2 +- crates/engine/src/slideshow.rs | 57 +++++++++++++++++++++++--- crates/engine/src/tests_prefs.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 5 +++ crates/ui-egui/src/panels/slideshow.rs | 8 ++-- crates/ui-egui/src/tests_slideshow.rs | 40 ++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/slideshow.md | 4 +- 8 files changed, 107 insertions(+), 15 deletions(-) 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/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_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/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..a1aaeab85 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -805,6 +805,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) }); 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/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 24aa0d728..311793e4f 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -150,6 +150,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/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..ce2653cd6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -756,7 +756,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 | 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 From 1c60abb5860e6ba7cee95f324940d7d59aec3e40 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:42:21 -0700 Subject: [PATCH 04/19] LRC-PRINT-TEMPLATES: template changes that can't be saved are undone print.template.save, delete and import changed the template list before saving the preferences, and left the change in place when the save failed. A retried import then saw its own unsaved copy and stored another one as "Name (2)". The list now goes back to what it was when the save fails, as library.holdView and the slide templates do. --- crates/engine/src/cmd/print.rs | 22 +++++++++++--- crates/engine/src/tests_print.rs | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 541773273..1390ca167 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -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,12 +320,12 @@ 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)) } @@ -360,8 +371,9 @@ fn template_import(s: &mut Session, p: &Value) -> Result { 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); - s.save_prefs()?; + save_templates(s, before)?; let mut out = template_list(s); out["name"] = json!(name); out["renamed"] = json!(renamed); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index ca6ea486c..3e9aae671 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -365,6 +365,58 @@ fn templates_export_to_a_file_and_import_from_one() { let _ = std::fs::remove_dir_all(dir); } +/// 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 dir = std::env::temp_dir().join(format!("lightcraft-print-unsaved-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.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}"); + } + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_hostile_template_in_the_preferences_does_not_break_the_library() { let files = MemStore::new(); From 758c5e170a455aca3170adb6aff5aa4cecb16612 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:13:49 -0700 Subject: [PATCH 05/19] LRC-DEV-VIEWOPTIONS: the info overlay can show briefly when the photo changes View Options had no Show Briefly When Photo Changes. A tick box under Loupe Info 1 and one under Loupe Info 2 now choose the Info that shows for 3 seconds each time the loupe changes to another photo while the info overlay is off. Ticking one unticks the other. An overlay that is on stays on and shows its own Info. view.viewOptions takes briefly (off, basic or exposure), and the choice is saved with the app settings; a settings file without it reads as off. --- crates/ui-egui/locales/it.json | 1 + crates/ui-egui/src/headless.rs | 47 ++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 10 ++++- crates/ui-egui/src/panels/detail.rs | 57 ++++++++++++++++++++++++++- crates/ui-egui/src/panels/settings.rs | 10 ++++- crates/ui-egui/src/state.rs | 33 +++++++++++++++- docs/parity-checklist.md | 2 +- 7 files changed, 153 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 759fe53d0..4726742de 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2344,6 +2344,7 @@ "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." } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c33ba0998..b219138b3 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4608,6 +4608,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] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 747cfbccb..7641272b9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -742,7 +742,8 @@ 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, and `briefly` sets the Info that Show Briefly When Photo + // Changes shows, for an agent; without them the window opens let mut info = app.ui.settings.loupe_info; let mut set = false; for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { @@ -755,6 +756,13 @@ 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; } else { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 082508d08..20a39a2fa 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -340,6 +340,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 overlays (one mask, or every visible mask at once) are evaluated from the same mask /// alphas as the full render; the other diagnostic overlays either need whole-frame context or @@ -475,6 +494,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 }; @@ -1024,7 +1044,10 @@ fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraf /// 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, @@ -3435,6 +3458,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/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..b539358ff 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -691,8 +691,8 @@ 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, and which of them shows briefly when the +/// photo changes (`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")); @@ -727,6 +727,12 @@ 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 }; + } } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8cb37d3ef..11462a155 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -247,12 +247,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 { @@ -264,7 +283,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 } } } @@ -967,6 +986,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. @@ -1472,6 +1494,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(), @@ -1619,13 +1642,19 @@ 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()); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..ed14924fa 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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; applied as they change, saved with the app settings; `view.viewOptions {info1, info2, briefly}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), the loading-message and video-frame options | | 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-MAP-VIEW | Map view | P2 | ⬜ | | | From a4ddb9198f7dd72caaa01ea2a410e0d2ac1930be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:33:44 -0700 Subject: [PATCH 06/19] =?UTF-8?q?LRC-DEV-VIEWOPTIONS:=20View=20Options=20c?= =?UTF-8?q?an=20turn=20off=20the=20loupe's=20"Rendering=E2=80=A6"=20messag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View Options gets the Loupe View tab's General section with "Show message when loading or rendering photos". It is on by default, so the loupe keeps saying "Rendering…" as before. With it off, the loupe does not show the "Rendering…" pill while an edit renders, or the "Rendering…" text over an empty loupe; the "can't be opened" messages still show. The choice is saved with the app settings, and `view.viewOptions {loupeMessages}` sets it for an agent. --- crates/ui-egui/locales/it.json | 1 + crates/ui-egui/src/headless.rs | 63 +++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 16 ++++++- crates/ui-egui/src/panels/detail.rs | 7 ++- crates/ui-egui/src/panels/settings.rs | 6 ++- crates/ui-egui/src/state.rs | 18 ++++++++ docs/parity-checklist.md | 2 +- 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 759fe53d0..f758a44b4 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2341,6 +2341,7 @@ "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", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c33ba0998..ca86d82a1 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4944,6 +4944,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/menus.rs b/crates/ui-egui/src/menus.rs index 747cfbccb..4302e3058 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -742,9 +742,20 @@ 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` and `loupeMessages` set the options 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()) { @@ -757,10 +768,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 082508d08..8b0f47660 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -796,9 +796,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 => {} } } } @@ -950,9 +951,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) { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..279d920e5 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -691,8 +691,8 @@ 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, 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")); @@ -728,6 +728,8 @@ pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Token *lines = defaults; } } + 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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8cb37d3ef..9d81a501d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -438,6 +438,9 @@ pub struct AppSettings { 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. @@ -483,6 +486,7 @@ impl Default for AppSettings { film_names: true, film_badges: true, grid_badges: GridBadges::Auto, + loupe_messages: true, keymap: Default::default(), appearance_mode: AppearanceMode::Dark, dark_theme: DarkTheme::Charcoal, @@ -1631,4 +1635,18 @@ mod loupe_info_tests { 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/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..2cbeb33ce 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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; 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, loupeMessages}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", 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-MAP-VIEW | Map view | P2 | ⬜ | | | From 0b1b47ee7b3c32e06a587a696073a527d78850cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 06:22:46 -0700 Subject: [PATCH 07/19] LR-META-INFO: the Info panel can show metadata for the selected photos With several photos selected, the Info panel offers Show Metadata For: Target Photo (the default) shows the active photo's values as before, and Selected Photos shows the values the selected photos share. A text field or GPS where they differ is empty with a Mixed hint, and Copyright Status shows Mixed. What is typed there is saved to every selected photo, and a Mixed field typed in and left empty clears it on all of them. A field left as it was saves nothing, even when the choice changes during the edit. The choice is saved with the app settings, and `view.infoSelectedPhotos {on?}` sets it for an agent. --- crates/ui-egui/src/headless.rs | 145 ++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 12 ++ crates/ui-egui/src/panels/right.rs | 174 ++++++++++++++++++++++++----- crates/ui-egui/src/state.rs | 5 + docs/control-protocol.md | 6 + docs/gaps.md | 5 +- docs/parity-checklist.md | 2 +- 7 files changed, 320 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a5d05edac..e06903fb0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2902,6 +2902,151 @@ 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, "Target Photo by default"); + // Target Photo: the title field holds the active photo's title, and what is typed after it + // goes to both photos + 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!((meta(&h, a).title, meta(&h, b).title), ("Dawn!".into(), "Dawn!".into())); + // Selected Photos, from its button + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + 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 Target Photo + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(saved["infoSelectedPhotos"], true); + 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)); + } + + /// 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"]); + } + /// 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] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..0b7de30a0 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"), @@ -1114,6 +1116,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") { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a29534be3..4b88aa20f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -675,7 +675,15 @@ 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 mixed = if several && app.ui.settings.info_selected_photos { 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 +691,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 +748,33 @@ 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)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed)); + let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); + let r = ui.add(egui::TextEdit::singleline(&mut edit.text).hint_text(hint).desired_width(f32::INFINITY)); crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); register(ui.ctx(), "field:gps", r.rect); + edit.typed |= edit.text != edit.start; if r.has_focus() { - ui.data_mut(|d| d.insert_temp(gid, text.clone())); + 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)); } + let text = edit.text.trim(); if r.lost_focus() - && text.trim() != gps - && let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()})) + && (text != edit.start || edit.clears()) + && let Err(e) = app.run("photo.setMeta", json!({"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 +909,117 @@ 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. +#[derive(Clone)] +struct FieldEdit { + text: String, + start: String, + /// 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) -> Self { + FieldEdit { text: value.to_string(), start: value.to_string(), 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 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 +1028,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)); 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!({key: edit.text})); } ui.add_space(6.0); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 53d5b8718..353514da8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -458,6 +458,10 @@ 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: false shows the active + /// photo's values (Target Photo), true shows the values the selected photos share and marks + /// each field where they differ (Selected Photos) (`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. @@ -526,6 +530,7 @@ impl Default for AppSettings { photocraft_path: String::new(), photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, + info_selected_photos: false, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7a8033aa5..4599cc7b6 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, the default) shows the active photo's values, on (Selected Photos) +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 a8cdb5e5c..5f6df76de 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 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 @@ -44,7 +44,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 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) | +| 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,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 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/parity-checklist.md b/docs/parity-checklist.md index 7fa952efb..9dee5de4d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 Target Photo (the active photo's values, the default) or Selected Photos (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). 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 | From d2c8d46a7ba841558f76993e53bf1db63e716e0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:03:17 -0700 Subject: [PATCH 08/19] Tests: the launch-list read-only test compares a held lock file without reading it On Windows, tests_launch_library::listing_the_libraries_leaves_every_folder_byte_identical failed every time with os error 33. Its snapshot read every file in the folder, including the lock file of the library the test holds with LibraryLock::acquire. On Windows that lock is LockFileEx, and reading the locked bytes through another handle fails with ERROR_LOCK_VIOLATION. The snapshot now records each file's length as well as its contents and modification time. When the read of a catalog.lock fails with os error 33 on Windows, its contents are left out and the file is compared by its length and modification time. Every other file is still compared byte for byte, and on other platforms the held lock file is too. The app does not read a held lock file anywhere, so only the test changes. --- crates/ui-egui/src/tests_launch_library.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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] From 9fcf7f5b88523899ea2136571109f543ffe3a0c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:24:49 -0700 Subject: [PATCH 09/19] LRC-DEV-VIEWOPTIONS: switching libraries restarts Show Briefly When Photo Changes Photo IDs are local to a library, so the loupe's last photo is forgotten when another library opens, and a photo there with the same ID shows the info overlay again. --- crates/ui-egui/src/panels/settings.rs | 1 + crates/ui-egui/src/tests_switch_library.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b539358ff..1aee3c0b1 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -1187,6 +1187,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/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 2ea310b04..832134f10 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); From a9d2c594b05ff80ae47ebc192db84a2df7cd0401 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:56:00 -0700 Subject: [PATCH 10/19] LRC-PRINT-TEMPLATES: print template tests clean up their folder on failure The template file tests now create their folder through a Scratch guard that removes it when the test ends, including when an assertion fails, as tests_folders.rs and tests_slideshow_export.rs do. --- crates/engine/src/tests_print.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 3e9aae671..5e9a14415 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"}}})); } @@ -304,8 +322,8 @@ fn templates_export_to_a_file_and_import_from_one() { s.open_library_in(stores, false).unwrap(); s }; - let dir = std::env::temp_dir().join(format!("lightcraft-print-templates-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + 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"})); @@ -362,7 +380,6 @@ fn templates_export_to_a_file_and_import_from_one() { 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")); - let _ = std::fs::remove_dir_all(dir); } /// A library whose `prefs.json` can't be read, so saving the preferences fails. @@ -399,9 +416,8 @@ fn template_changes_that_cant_be_saved_are_undone() { 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 dir = std::env::temp_dir().join(format!("lightcraft-print-unsaved-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("proofs.lcprint"); + 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"}})); @@ -414,7 +430,6 @@ fn template_changes_that_cant_be_saved_are_undone() { assert!(fails(&mut s, id, p.clone()).contains("prefs.json"), "{id} {p}"); assert_eq!(names(&s), kept, "{id} {p}"); } - let _ = std::fs::remove_dir_all(dir); } #[test] From cc557e189d06665df06dec3f339d9943d6e9d70f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:59:41 -0700 Subject: [PATCH 11/19] KEYC-METADATA: Cmd+Alt+Shift+C / V copy and paste metadata Copy Metadata and Paste Metadata had no shortcut. They now use Cmd+Option+Shift+C and Cmd+Option+Shift+V on macOS and Ctrl+Alt+Shift+C and Ctrl+Alt+Shift+V on Windows and Linux, the keys Lightroom Classic's keyboard shortcut page lists for copying and pasting metadata in the Library module. Both keys also work when Windows and Linux deliver them as clipboard events. The KEYC-METADATA row now lists the keys LightCraft binds (Cmd+S, the two new keys and Alt+1 to Alt+9 for keyword sets) and every other key of that table that is still missing. Gap 28 in docs/gaps.md and the Library keys in docs/ui-parity.md list the two new keys. --- crates/engine/src/cmd/library.rs | 4 +-- crates/ui-egui/src/tests_keymap.rs | 45 ++++++++++++++++++++++++++++++ docs/gaps.md | 5 ++-- docs/library-shortcuts.md | 2 ++ docs/parity-checklist.md | 2 +- docs/ui-parity.md | 5 ++-- 6 files changed, 56 insertions(+), 7 deletions(-) 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/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index b48da7cdf..e630cc6cb 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -176,6 +176,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/docs/gaps.md b/docs/gaps.md index ceea3724f..ec0b99622 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 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,7 +43,7 @@ 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) | +| 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, 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) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -84,6 +84,7 @@ 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-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..5ce3e3fda 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -18,6 +18,8 @@ Select photos in Photo Grid or Square Grid, then use: | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | | `\` (backslash) | Show / hide the Filter Bar | | ⌘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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d059494ce..f97b259dd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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:keyword.toggleFromSet`, `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; ⌥1–⌥9 toggle the current keyword set's keywords. Missing: ⌘K, ⌘⇧K, ⌘⌥⇧K, ⇧K, ⌘⌥K painting, ⌥0 / ⌥⇧0 keyword set cycling, ⌘⌥⇧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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 16665f3d4..a07eced1a 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) · **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,7 +66,7 @@ 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). @@ -148,6 +148,7 @@ 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-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 | From e25cc3ca0b7548577467a99d3b2b563b7de57ab1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:43:44 -0700 Subject: [PATCH 12/19] LR-META-INFO: Selected Photos reads the selection when the active photo is outside it The active photo can be one that is not selected (library.select with an `active` outside `ids`). With Selected Photos on, the Info fields then showed that photo's values and marked fields Mixed against it. The fields now show the active photo's values only when it is selected, and else the first selected photo's, and compare the selected photos with those. --- crates/ui-egui/src/headless.rs | 31 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 20 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e06903fb0..fe4e255d4 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3047,6 +3047,37 @@ mod tests { 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())); + } + /// 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] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 4b88aa20f..e5a454559 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -674,12 +674,13 @@ 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 mixed = if several && app.ui.settings.info_selected_photos { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; + 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); @@ -971,6 +972,21 @@ fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> }) } +/// 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> { From 8d67b834e4c457b5be2cd1946f809ac7dbd998fd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:57:22 -0700 Subject: [PATCH 13/19] LR-PREF-SHORTCUTS: a declared shortcut yields to a key the saved keymap gives another command When a saved keymap gave a key to one command and a later build declared the same key as another command's default, one press ran both commands, and menus and Help > Keyboard Shortcuts listed the key on both. binding() now drops a declared shortcut while the keymap gives its key to another known command, other than its contextual partner. handle(), conflicts(), shortcut_of(), the menus and the shortcut editor read it. In the editor such a row counts as changed, so its restore button takes the key back. handle() also let every saved entry stop the fixed keys on its key (Enter in the grid, secondary keys such as Shift+X, the rating and label digits, Home, End and Shift+arrows), including entries for commands the build does not know. It now reads only the entries of known commands, as binding() does, so a stale entry in ui.json no longer silences Shift+X, a rating digit or a label digit. --- crates/ui-egui/src/panels/keymap.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 67 +++++++++++++++++++++++++++-- crates/ui-egui/src/tests_keymap.rs | 21 +++++++++ docs/parity-checklist.md | 2 +- 4 files changed, 87 insertions(+), 6 deletions(-) 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/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/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index b48da7cdf..9f2e0504a 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() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d059494ce..df3481f67 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 | ⬜ | | | From b572dee19f98e125d2c15c7ad947f85cf6ebff8d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:23:40 -0700 Subject: [PATCH 14/19] KEYC-METADATA: Alt+0 / Alt+Shift+0 go to the next / previous keyword set Lightroom Classic's keyboard shortcut page lists Option+0 / Option+Shift+0 (Alt+0 / Alt+Shift+0 on Windows) for cycling forward and backward through the keyword sets; LightCraft had no key or command for it. Next Keyword Set (keyword.nextSet, Alt+0) and Previous Keyword Set (keyword.previousSet, Alt+Shift+0) make the set after or before the current one current, in the Keyword Set menu's order: Recent Keywords, then the saved sets. The keys wrap around at either end, and Alt+1 to Alt+9 then apply the new set's keywords. The two command labels have rows in every catalog. The KEYC-METADATA and LRC-LIB-KEYWORDS rows, docs/ui-parity.md and docs/library-shortcuts.md list the keys. --- crates/engine/src/cmd/keywords.rs | 28 +++++++++++++++++++++++++ crates/engine/src/tests_keyword_list.rs | 23 ++++++++++++++++++++ crates/ui-egui/locales/cs.json | 2 ++ crates/ui-egui/locales/de.json | 2 ++ crates/ui-egui/locales/es.json | 2 ++ crates/ui-egui/locales/fi.json | 2 ++ crates/ui-egui/locales/fr.json | 2 ++ crates/ui-egui/locales/id.json | 2 ++ crates/ui-egui/locales/it.json | 2 ++ crates/ui-egui/locales/ja.json | 2 ++ crates/ui-egui/locales/ka.json | 2 ++ crates/ui-egui/locales/pl.json | 2 ++ crates/ui-egui/locales/pt-br.json | 2 ++ crates/ui-egui/locales/ru.json | 2 ++ crates/ui-egui/locales/sv.json | 2 ++ crates/ui-egui/locales/uk.json | 2 ++ crates/ui-egui/locales/vi.json | 2 ++ crates/ui-egui/locales/zh-hans.json | 2 ++ crates/ui-egui/locales/zh-hant.json | 2 ++ crates/ui-egui/src/tests_keyword_set.rs | 17 +++++++++++++++ docs/library-shortcuts.md | 13 +++++++++++- docs/parity-checklist.md | 7 ++++--- docs/ui-parity.md | 5 ++++- 23 files changed, 122 insertions(+), 5 deletions(-) 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/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/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 16095a43e..5d2f3f719 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -1193,6 +1193,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 +1403,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..bd96833e8 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -950,6 +950,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 +1113,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..b7011fa77 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -961,6 +961,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 +1118,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..afb724797 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -1236,6 +1236,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 +1462,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..8d28beee1 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -904,6 +904,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 +1060,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..afad7fc07 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -1233,6 +1233,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 +1446,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..f08aa2741 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -907,6 +907,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 +1069,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…", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c3f11e6e5..ee29a16e5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -632,6 +632,7 @@ "Nested group": "入れ子のグループ", "New Album": "新しいアルバム", "New time": "新しい日時", + "Next Keyword Set": "次のキーワードセット", "Next Photo": "次の写真", "Night Blue": "ナイトブルー", "No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません", @@ -672,6 +673,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..a363d0087 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -1310,6 +1310,7 @@ "New…": "ახალი…", "Next Appearance Mode": "შემდეგი იერსახის რეჟიმი", "Next History Step": "ისტორიის შემდეგი ნაბიჯი", + "Next Keyword Set": "საკვანძო სიტყვების შემდეგი ნაკრები", "Next Photo": "შემდეგი ფოტო", "Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…", "Night Blue": "ღამის ლურჯი", @@ -1534,6 +1535,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..67a40ec57 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -886,6 +886,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 +1031,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..b82d3aa01 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -916,6 +916,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 +1078,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..194ce6014 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -760,6 +760,7 @@ "New blank PDF": "Новый пустой PDF", "New name": "Новое имя", "New…": "Новый…", + "Next Keyword Set": "Следующий набор ключевых слов", "Next Photo": "Следующее фото", "Night Blue": "Ночной синий", "No": "Нет", @@ -897,6 +898,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..356487b9d 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -1362,6 +1362,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 +1600,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..9d07b67a5 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -855,6 +855,7 @@ "Nested group": "Вкладена група", "New Album": "Новий альбом", "New time": "Новий час", + "Next Keyword Set": "Наступний набір ключових слів", "Next Photo": "Наступне фото", "Night Blue": "Нічна синява", "No named people in these photos": "На цих фото немає людей з іменами", @@ -887,6 +888,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..a5b27d8ac 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -1136,6 +1136,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 +1339,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..7b4a2c615 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -981,6 +981,7 @@ "New blank PDF": "新建空白 PDF", "New name": "新名称", "New…": "新建…", + "Next Keyword Set": "下一个关键字集", "Next Photo": "下一张照片", "Night Blue": "夜色蓝调", "No": "否", @@ -1109,6 +1110,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..d9e268335 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -982,6 +982,7 @@ "New blank PDF": "新增空白 PDF", "New name": "新名稱", "New…": "新增…", + "Next Keyword Set": "下一個關鍵字集", "Next Photo": "下一張照片", "Night Blue": "夜色藍調", "No": "否", @@ -1110,6 +1111,7 @@ "Preview only": "僅供預覽", "Preview size": "預覽尺寸", "Previews": "預覽", + "Previous Keyword Set": "上一個關鍵字集", "Previous Photo": "上一張照片", "Print": "列印", "Print…": "列印…", 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/docs/library-shortcuts.md b/docs/library-shortcuts.md index 81bc2cede..b78f68960 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -83,7 +83,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..6091e9d60 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) · **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 @@ -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 @@ -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:keyword.toggleFromSet`, `cmd:keyword.nextSet`, `cmd:keyword.previousSet` | ⌘S saves metadata; ⌥1–⌥9 toggle the current keyword set's keywords, ⌥0 / ⌥⇧0 go to the next / previous set. Missing: ⌘⌥⇧C / ⌘⌥⇧V metadata copy/paste, the ⌘K, ⌘⇧K, ⌘⌥⇧K, ⇧K and ⌘⌥K keyword and painting keys, ⌘⌥⇧F Visual Search, ⇧O Edit Face name (⇧O cycles the crop overlay here), 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,7 @@ 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 | 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/ui-parity.md b/docs/ui-parity.md index 16665f3d4..c50116634 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: ⌥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) · **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 @@ -72,6 +72,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **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 +150,7 @@ establish OS keyboard delivery or cross-platform acceptance. | Date | Change | Summary | |---|---|---| +| 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-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 | From 7a4e842dd58a5bc6e1dcba36488dbd994c050ea7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:51:15 -0700 Subject: [PATCH 15/19] LR-META-INFO: an Info field saves to the photos selected when its edit started The text fields and GPS saved with no `ids`, so `photo.setMeta` wrote to whatever was selected when the edit ended. A selection change during an edit then gave the typed text to the new selection. Each edit now keeps the selected photos from its start and saves to them. --- crates/ui-egui/src/headless.rs | 41 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 18 ++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index fe4e255d4..b7343b169 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3078,6 +3078,47 @@ mod tests { 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]); + } + /// 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] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index e5a454559..6da043831 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -756,7 +756,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; let gid = egui::Id::new("info-gps"); ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); - let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed)); + 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 = ui.add(egui::TextEdit::singleline(&mut edit.text).hint_text(hint).desired_width(f32::INFINITY)); crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); @@ -770,7 +770,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let text = edit.text.trim(); if r.lost_focus() && (text != edit.start || edit.clears()) - && let Err(e) = app.run("photo.setMeta", json!({"gps": text})) + && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) { app.toast(ui.ctx(), e); } @@ -915,11 +915,14 @@ const MIXED: &str = "Mixed"; /// An Info panel field's edit under way, kept in egui memory between frames. A field saves when /// its text is not the text the edit started from, so a field left as it was saves nothing, even -/// when what the panel shows changed during the edit. +/// 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. @@ -927,8 +930,9 @@ struct FieldEdit { } impl FieldEdit { - fn new(value: &str, mixed: bool) -> Self { - FieldEdit { text: value.to_string(), start: value.to_string(), mixed, typed: false } + 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. @@ -1053,7 +1057,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::new(("info-field", key)); let value = if mixed { "" } else { value }; - let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed)); + 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 edit.text).rows(lines) @@ -1071,7 +1075,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str } // Return (one line) or leaving saves; Esc gives the edit up if r.committed() && (edit.text != edit.start || edit.clears()) { - let _ = app.run("photo.setMeta", json!({key: edit.text})); + let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text})); } ui.add_space(6.0); } From 1b0a9dae1b33dc40a0d7491a28274bc3680c698a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:49:45 -0700 Subject: [PATCH 16/19] LR-META-INFO: the Info panel's GPS field is a TextField, so Esc gives an edit up The GPS box was an egui TextEdit: Esc left the field with the typed text kept, and leaving the field saved it. It is now the app's TextField, as the other Info fields are: Esc puts the text back and saves nothing, and the field gets the Cut / Copy / Paste / Select All menu. --- crates/ui-egui/src/headless.rs | 11 +++++++++++ crates/ui-egui/src/panels/right.rs | 9 ++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b7343b169..296a1247b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3117,6 +3117,17 @@ mod tests { 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 diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6da043831..1afad9bc3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -758,17 +758,16 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &selected)); let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); - let r = ui.add(egui::TextEdit::singleline(&mut edit.text).hint_text(hint).desired_width(f32::INFINITY)); - crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); - register(ui.ctx(), "field:gps", r.rect); + 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.has_focus() { + if r.editing { ui.data_mut(|d| d.insert_temp(gid, edit.clone())); } else { ui.data_mut(|d| d.remove::(gid)); } + // Return or leaving saves; Esc gives the edit up let text = edit.text.trim(); - if r.lost_focus() + if r.committed() && (text != edit.start || edit.clears()) && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text})) { From beb42abb25d7ea1d6baaae6fca62b8ca0fc86a44 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 16:25:06 -0700 Subject: [PATCH 17/19] KEYC-METADATA: Cmd+K / Ctrl+K puts the cursor in the Add keyword box Lightroom Classic's keyboard shortcut page lists Command+K (Ctrl+K on Windows) for Add keywords. LightCraft had no key for it. Add Keywords (panel.addKeywords, Cmd+K) shows the Keywords panel, opens its Keywording section when it is folded shut, and puts the cursor in the Add keyword box, so the next keywords typed go to the selected photos. Pressed again, it leaves the panel open. With no photo selected the panel shows its note, and selecting photos later does not move the cursor into the box. The command label has a row in every catalog. The KEYC-METADATA row, docs/ui-parity.md and docs/library-shortcuts.md list the key. The KEYC-METADATA row also lists the existing Alt+1 to Alt+9 keyword set keys in place of "no keyword sets". --- crates/ui-egui/locales/cs.json | 1 + crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fi.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/id.json | 1 + crates/ui-egui/locales/it.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/ka.json | 1 + crates/ui-egui/locales/pl.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/sv.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/vi.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/menus.rs | 11 ++++++++ crates/ui-egui/src/panels/right.rs | 9 ++++++- crates/ui-egui/src/state.rs | 4 +++ crates/ui-egui/src/tests_keywording.rs | 36 ++++++++++++++++++++++++++ docs/library-shortcuts.md | 1 + docs/parity-checklist.md | 5 ++-- docs/ui-parity.md | 5 +++- 24 files changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 16095a43e..e37d0f6ef 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", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 679e3b00f..a39f48062 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", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index af22570ef..23e7a3693 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", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 6456a6624..677f41b7f 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", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 33a892ecf..862bb41af 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", diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index abb6a732e..a0ba78e19 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", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index a75448fbb..06eda7dd4 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", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c3f11e6e5..13699d4f9 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": "アルバムに追加", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 13fd48d3c..e45ca68bb 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": "ალბომში დამატება", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index f03a81762..5bb85cd6d 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", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 88e955d26..9cc917ac1 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", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 5c8816c5e..8c54b2a76 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": "Добавить в альбом", diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index d9af4b8e8..732299c3f 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", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 39090c0b3..154a3cd41 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": "Додати до улюблених", diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index 98adc91b8..7ad80e8ea 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", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 73caf6407..cd25e40c0 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": "添加到相册", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 72cc3ab7f..94d88fcbc 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": "加入相簿", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a162e6ca6..5e51ab060 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -158,6 +158,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, ""), @@ -1242,6 +1244,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/right.rs b/crates/ui-egui/src/panels/right.rs index a29534be3..0b90cd55b 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") { @@ -1014,11 +1016,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/state.rs b/crates/ui-egui/src/state.rs index 53d5b8718..df2707227 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -900,6 +900,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)>, @@ -1508,6 +1511,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, 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/docs/library-shortcuts.md b/docs/library-shortcuts.md index 81bc2cede..5d75c099f 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -17,6 +17,7 @@ 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 | 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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d059494ce..decf98aa4 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: ⌘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 @@ -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:panel.addKeywords`, `cmd:keyword.toggleFromSet` | ⌘S saves metadata; ⌘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. Missing: metadata copy/paste | | 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,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 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/ui-parity.md b/docs/ui-parity.md index 16665f3d4..c3753dc48 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: ⌘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 @@ -70,6 +70,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **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. **Still missing / broken:** @@ -148,6 +150,7 @@ establish OS keyboard delivery or cross-platform acceptance. | Date | Change | Summary | |---|---|---| +| 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 | From c3feda15c0912eab8b9f23a205955f1054342fb2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:36:14 +0000 Subject: [PATCH 18/19] LR-META-INFO: the Info panel shows Selected Photos by default; gaps.md lists its change notes once --- crates/ui-egui/src/headless.rs | 56 +++++++++++++++++++++++++--------- crates/ui-egui/src/state.rs | 8 ++--- docs/control-protocol.md | 2 +- docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6c0d2b2a8..4b530ad57 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2955,17 +2955,11 @@ mod tests { 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, "Target Photo by default"); - // Target Photo: the title field holds the active photo's title, and what is typed after it - // goes to both photos - 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!((meta(&h, a).title, meta(&h, b).title), ("Dawn!".into(), "Dawn!".into())); - // Selected Photos, from its button - exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"})); + 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 @@ -3018,19 +3012,51 @@ mod tests { 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 Target Photo + // 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"], true); + assert_eq!(saved["infoSelectedPhotos"], false); let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap(); - assert!(read.info_selected_photos); + assert!(!read.info_selected_photos); let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap(); - assert!(!old.info_selected_photos); + 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] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 59ec8296d..decdf6364 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -477,9 +477,9 @@ 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: false shows the active - /// photo's values (Target Photo), true shows the values the selected photos share and marks - /// each field where they differ (Selected Photos) (`view.infoSelectedPhotos`). + /// 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, @@ -552,7 +552,7 @@ impl Default for AppSettings { photocraft_path: String::new(), photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, - info_selected_photos: false, + info_selected_photos: true, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4599cc7b6..68b5a97d9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -137,7 +137,7 @@ file, on a background job (activity kind `focus`); `wait: true` returns the run' With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or `on: null`, toggles; any other value that is not true or false is refused; replies `{on}`; -`settings.infoSelectedPhotos`): off (Target Photo, the default) shows the active photo's values, on (Selected Photos) +`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. diff --git a/docs/gaps.md b/docs/gaps.md index e8e58012d..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-24 · **Change:** minor (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; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; 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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2ab5a82a9..dcdaec565 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -164,7 +164,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Target Photo (the active photo's values, the default) or Selected Photos (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). 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 | From 31ec62cd0387abecd8f2a2da034aee591447897d Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:58:10 -0400 Subject: [PATCH 19/19] Info panel Target Photo edits, Add Keywords cursor request and Fixed keys follow-ups (#2889) * LR-PREF-SHORTCUTS: the shortcuts panel's Fixed keys ignore keys saved for unknown commands * KEYC-METADATA: an Add Keywords request the Keywords panel can't take in time expires * LR-META-INFO: Target Photo saves an Info edit to the photo it shows --- crates/ui-egui/src/headless.rs | 31 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/keymap.rs | 3 +- crates/ui-egui/src/panels/right.rs | 42 +++++++++++++++----------- crates/ui-egui/src/shortcuts.rs | 2 +- crates/ui-egui/src/state.rs | 12 ++++++-- crates/ui-egui/src/tests_keywording.rs | 13 ++++++++ docs/control-protocol.md | 2 +- docs/parity-checklist.md | 2 +- 9 files changed, 83 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 4b530ad57..8686dbdf5 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3057,6 +3057,37 @@ mod tests { assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into())); } + /// Target Photo edits the photo it shows: with several photos selected, a field's edit is + /// saved to the active photo alone. + #[test] + fn info_target_photo_edits_only_the_photo_it_shows() { + let mut h = demo([1300.0, 1000.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.step(); + h.step(); + }; + let exec = |h: &mut Headless, command: &str, params: serde_json::Value| { + ask(h, "engine.execute", json!({"command": command, "params": params})); + }; + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone(); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"})); + exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"})); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]})); + exec(&mut h, "view.infoSelectedPhotos", json!({"on": false})); + h.settle(SETTLE); + ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "!"})); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + h.settle(SETTLE); + assert_eq!((caption(&h, 0), caption(&h, 1)), ("Dawn!".into(), "Dusk".into())); + } + /// The fields the Info panel marks as mixed: each text field, the copyright status and GPS /// where a selected photo differs from the photo the panel shows, and none for one photo. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f6691a861..8c249dd36 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1283,7 +1283,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index 11290b1b1..1dc7ffbb9 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -172,7 +172,8 @@ fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, nee } ui.add_space(8.0); ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text)); - let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect(); + // keys saved for commands this build knows: an entry for an unknown command takes no key + let taken: Vec<_> = shortcuts::saved_keys(&app.ui.settings.keymap).map(|(_, k)| k).collect(); egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { for (sc, label) in fixed { ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label)); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3cb5f00f7..feb201918 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -21,7 +21,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { // (without a selection there is no Add keyword box for Add Keywords to put the cursor in) - app.ui.focus_keywords = false; + app.ui.focus_keywords = None; egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; if fold_header(app, ui, "keywording", "Keywords") { @@ -683,6 +683,8 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let for_selected = several && app.ui.settings.info_selected_photos; let m = shown_meta(&app.session.catalog, &p, &selected, for_selected); let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() }; + // the photos an edit goes to: Target Photo edits the photo it shows + let targets = if several && !for_selected { vec![id] } else { selected.clone() }; padded(ui, |ui| { if several { metadata_for(app, ui); @@ -694,15 +696,15 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Extended Description", "extendedDescription", &m.extended_description, 3), ("Copyright", "copyright", &m.copyright, 1), ] { - meta_field(app, ui, label, key, value, lines, mixed.contains(&key)); + meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); } - copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus")); + copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"), &targets); for (label, key, value, lines) in [ ("Rights Usage Terms", "usageTerms", &m.usage_terms, 2), ("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1), ("Creator", "creator", &m.creator, 1), ] { - meta_field(app, ui, label, key, value, lines, mixed.contains(&key)); + meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets); } // file: name (rename), path (reveal), capture time (edit) let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim)); @@ -751,14 +753,14 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("State / Province", "state", &m.state), ("Country", "country", &m.country), ] { - meta_field(app, ui, label, key, value, 1, mixed.contains(&key)); + meta_field(app, ui, label, key, value, 1, mixed.contains(&key), &targets); } // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it let gps_mixed = mixed.contains(&"gps"); let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; let gid = egui::Id::new("info-gps"); ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); - let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &selected)); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets)); let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" }); let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui); edit.typed |= edit.text != edit.start; @@ -916,13 +918,14 @@ const MIXED: &str = "Mixed"; /// An Info panel field's edit under way, kept in egui memory between frames. A field saves when /// its text is not the text the edit started from, so a field left as it was saves nothing, even -/// when what the panel shows changed during the edit. It saves to the photos selected when the -/// edit started, also when the selection changed since. +/// when what the panel shows changed during the edit. It saves to the photos it edited when the +/// edit started (the selected photos, or Target Photo's photo alone), also when the selection +/// changed since. #[derive(Clone)] struct FieldEdit { text: String, start: String, - /// The selected photos when the edit started (`photo.setMeta` `ids`). + /// The photos the field edited when the edit started (`photo.setMeta` `ids`). ids: Vec, /// The field said Mixed when the edit started. mixed: bool, @@ -931,8 +934,8 @@ struct FieldEdit { } impl FieldEdit { - fn new(value: &str, mixed: bool, selected: &[PhotoId]) -> Self { - let ids = selected.iter().map(|id| id.0).collect(); + fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self { + let ids = targets.iter().map(|id| id.0).collect(); FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false } } @@ -1032,8 +1035,9 @@ fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) { } /// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the -/// selected photos' statuses differ, and any status picked is given to all of them. -fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool) { +/// selected photos' statuses differ, and any status picked is given to all of them. A status +/// picked goes to the photos `targets`. +fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool, targets: &[PhotoId]) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() }); @@ -1041,7 +1045,8 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr for st in lightcraft_catalog::CopyrightStatus::ALL { let on = !mixed && st == current; if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on { - let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); + let ids: Vec = targets.iter().map(|id| id.0).collect(); + let _ = app.run("photo.setMeta", json!({"ids": ids, "copyrightStatus": st.id()})); } } }); @@ -1052,13 +1057,14 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr /// A labeled metadata text field: the typed text lives in egui memory while focused and is /// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values /// differ, so the field starts empty and says so, and is saved only when it was typed in (left -/// empty, it clears the field on every selected photo). -fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool) { +/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`. +#[allow(clippy::too_many_arguments)] +fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool, targets: &[PhotoId]) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::new(("info-field", key)); let value = if mixed { "" } else { value }; - let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, &app.session.selected())); + let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets)); let widget = format!("field:{key}"); let field = if lines > 1 { crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines) @@ -1158,7 +1164,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let bid = egui::Id::new("kw-begin"); let begin = ui.data_mut(|d| d.remove_temp::(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); // Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight - let asked = std::mem::take(&mut app.ui.focus_keywords); + let asked = app.ui.focus_keywords.take().is_some_and(|at| pass <= at.saturating_add(crate::state::FOCUS_KEYWORDS_PASSES)); let r = crate::text_field::TextField::singleline("field:keyword", &mut text) .hint(crate::i18n::tr("Add keyword")) .width(f32::INFINITY) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 32fc6bea4..82443438a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -74,7 +74,7 @@ fn assigned_elsewhere(keymap: &Keymap, id: &str, sc: &str) -> bool { /// The keys the keymap gives to known commands, with each command's id. Entries for commands this /// build doesn't know and entries that don't parse are ignored. -fn saved_keys(keymap: &Keymap) -> impl Iterator { +pub(crate) 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)?))) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index decdf6364..0bbd619d8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -271,6 +271,10 @@ pub struct LoupeInfo { pub briefly: InfoOverlay, } +/// Passes after Add Keywords within which the Add keyword box takes the cursor (the panel may +/// need a pass to open first). +pub const FOCUS_KEYWORDS_PASSES: u64 = 2; + /// How long Show Briefly When Photo Changes shows the info overlay (seconds). pub const BRIEF_INFO_SECS: f64 = 3.0; @@ -928,9 +932,11 @@ pub struct UiState { /// Focus the search field on the next frame (Edit → Find…). #[serde(skip)] pub focus_search: bool, - /// Put the cursor in the Keywords panel's Add keyword box when it is next drawn (Add Keywords). + /// Put the cursor in the Keywords panel's Add keyword box (Add Keywords): the egui pass it was + /// asked on. A box drawn more than [`FOCUS_KEYWORDS_PASSES`] passes later doesn't take it, so + /// a request made while the panel can't show expires. #[serde(skip)] - pub focus_keywords: bool, + pub focus_keywords: Option, /// A mask being renamed in the Masks list: its id and the edited name. #[serde(skip)] pub renaming_mask: Option<(u32, String)>, @@ -1542,7 +1548,7 @@ impl Default for UiState { known_cards: None, search: String::new(), focus_search: false, - focus_keywords: false, + focus_keywords: None, renaming_mask: None, describe: None, detail_due: None, diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 6148938e0..754a5abab 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -144,6 +144,19 @@ fn cmd_k_without_a_photo_leaves_the_cursor_alone() { assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box waits for a click"); } +/// An Add Keywords request the box wasn't drawn for in time (the panel couldn't show) expires: a +/// later draw of the box leaves the cursor alone. +#[test] +fn a_stale_add_keywords_request_leaves_the_cursor_alone() { + let (mut h, _) = two_selected(); + ask(&mut h, "ui.set", json!({"right": "keywords"})); + assert!(has(&h, "field:keyword")); + h.app.ui.focus_keywords = Some(h.view.ctx.cumulative_pass_nr().saturating_sub(crate::state::FOCUS_KEYWORDS_PASSES + 1)); + h.step(); + assert_eq!(h.app.ui.focus_keywords, None, "the request is used up"); + assert!(!h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))), "the box waits for a click"); +} + /// The keyword set's buttons say whether the selection has each keyword, as their ⌥1–⌥9 toggle it /// on the selection: on when every selected photo has it, partly on when only some do. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 68b5a97d9..a9020892f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -137,7 +137,7 @@ file, on a background job (activity kind `focus`); `wait: true` returns the run' With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or `on: null`, toggles; any other value that is not true or false is refused; replies `{on}`; -`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values, on (Selected Photos, the default) +`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values and saves edits to it alone, on (Selected Photos, the default) shows the values the selected photos share, leaves each text field and GPS where they differ empty with a Mixed hint, and shows Mixed as the Copyright Status when the statuses differ. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index dcdaec565..080680b9f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -164,7 +164,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Selected Photos (the default: the values they share; a text field or GPS where they differ is empty and says Mixed, Copyright Status shows Mixed, and only what is typed or picked there is saved, to every selected photo) or Target Photo (the active photo's values). No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Selected Photos (the default: the values they share; a text field or GPS where they differ is empty and says Mixed, Copyright Status shows Mixed, and only what is typed or picked there is saved, to every selected photo) or Target Photo (the active photo's values; an edit goes to that photo alone). No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |