Merge pull request #2876 from aiongg/dotcoocoo-batch

Integration batch: 10 community PRs (slideshow, print templates, View Options, Info panel, keyword and metadata keys)
This commit is contained in:
aiongg
2026-10-25 00:13:38 -04:00
committed by GitHub
53 changed files with 1607 additions and 86 deletions
+2
View File
@@ -228,6 +228,8 @@ fn services(originals: Originals, backend: Option<Backend>, 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,
+18
View File
@@ -706,6 +706,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_print_templates: Some(Box::new(|| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Import Print Templates"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
save_print_template: Some(Box::new(|name: &str| {
file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Export Print Template"))
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Print Templates"), &[lightcraft_engine::print::template::FILE_EXT])
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
+4
View File
@@ -70,3 +70,7 @@ sync_github_name = true
[people.ronibalthazar]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+28
View File
@@ -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<Value> {
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<CommandSpec> {
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<CommandSpec> {
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",
+2 -2
View File
@@ -726,7 +726,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
"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| {
+74 -6
View File
@@ -8,12 +8,12 @@
//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset`
//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear`
//! * `print.exportPdf {path, ids?}`
//! * `print.template.list|save|apply|delete`
//! * `print.template.list|save|apply|delete`, `print.template.export|import` (`.lcprint` files)
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, f64_req};
use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size};
use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name};
use crate::print::template::{FILE_EXT, MAX_FILE, MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name, unique_name};
use crate::{Result, Session};
fn rect_json(r: &Rect) -> Value {
@@ -277,10 +277,21 @@ fn template_name(p: &Value, id: &str) -> Result<String> {
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<PrintTemplate>) -> 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<Value> {
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<Value> {
s.print_templates.push(t);
}
}
s.save_prefs()?;
save_templates(s, before)?;
Ok(template_list(s))
}
@@ -309,15 +320,66 @@ fn template_apply(s: &mut Session, p: &Value) -> Result<Value> {
fn template_delete(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "print.template.delete";
let name = template_name(p, ID)?;
let before = s.print_templates.len();
let before = s.print_templates.clone();
s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name));
if s.print_templates.len() == before {
if s.print_templates.len() == before.len() {
return Err(bad(ID, format!("no print template `{name}`")));
}
s.save_prefs()?;
save_templates(s, before)?;
Ok(template_list(s))
}
fn template_path<'a>(p: &'a Value, id: &str) -> Result<&'a str> {
p.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(id, "missing `path`"))
}
fn template_export(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
const ID: &str = "print.template.import";
let path = std::path::Path::new(template_path(p, ID)?);
let meta = std::fs::metadata(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?;
if !meta.is_file() {
return Err(bad(ID, format!("{}: not a file", path.display())));
}
if meta.len() > MAX_FILE {
return Err(bad(ID, format!("{}: {} bytes is too large for a print template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024)));
}
let bytes = std::fs::read(path).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?;
let mut t = PrintTemplate::from_file(&bytes).map_err(|e| bad(ID, format!("{}: {e}", path.display())))?;
if s.print_templates.len() >= MAX_TEMPLATES {
return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first")));
}
let name = unique_name(&t.name, &s.print_templates);
let renamed = name != t.name;
t.name = name.clone();
let before = s.print_templates.clone();
s.print_templates.push(t);
save_templates(s, before)?;
let mut out = template_list(s);
out["name"] = json!(name);
out["renamed"] = json!(renamed);
Ok(out)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "print.get", "Print Settings", [], None,
@@ -349,5 +411,11 @@ pub fn specs() -> Vec<CommandSpec> {
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),
]
}
+1 -1
View File
@@ -379,7 +379,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+115
View File
@@ -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<Self, String> {
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<PrintTemplate> = 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}");
}
}
+51 -6
View File
@@ -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();
+23
View File
@@ -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<String> = (0..3).map(|_| current(s.execute("keyword.nextSet", &json!({})).unwrap())).collect();
assert_eq!(forward, ["Weddings", "Travel", "Recent Keywords"]);
let back: Vec<String> = (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]
+2 -2
View File
@@ -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
+139
View File
@@ -14,6 +14,24 @@ fn fails(s: &mut Session, id: &str, p: Value) -> String {
s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string()
}
/// A scratch folder that goes away with the test, however it ends.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("lightcraft-print-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn package(s: &mut Session) {
run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}}));
}
@@ -323,6 +341,125 @@ fn templates_save_apply_delete_and_persist() {
assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty());
}
#[test]
fn templates_export_to_a_file_and_import_from_one() {
let files = MemStore::new();
let open = |files: &MemStore| {
let mut s = Session::with_demo();
let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
s.open_library_in(stores, false).unwrap();
s
};
let scratch = Scratch::new("templates");
let dir = &scratch.0;
let mut s = open(&files);
run(&mut s, "print.set", json!({"layout": {"style": "contactSheet", "grid": {"columns": 3, "rows": 4}}, "page": {"background": [5, 6, 7]}}));
run(&mut s, "print.template.save", json!({"name": "Proofs"}));
// the extension is added when the path has none
let r = run(&mut s, "print.template.export", json!({"name": "proofs", "path": dir.join("proofs").to_string_lossy()}));
assert_eq!(r["name"], "Proofs");
let file = dir.join("proofs.lcprint");
assert_eq!(r["path"], file.to_string_lossy().as_ref());
let doc: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some("lightcraft.printTemplate"), Some(1), Some("Proofs")));
// into the same library: the name is taken, so the copy is renamed
let r = run(&mut s, "print.template.import", json!({"path": file.to_string_lossy()}));
assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs (2)"), Some(true)), "{r}");
assert_eq!(r["templates"].as_array().unwrap().len(), 2);
assert_eq!(r["templates"][1]["settings"], r["templates"][0]["settings"]);
// into another library, under its own name, and it is kept there
let other = MemStore::new();
let mut b = open(&other);
let r = run(&mut b, "print.template.import", json!({"path": file.to_string_lossy()}));
assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("Proofs"), Some(false)));
let g = run(&mut open(&other), "print.template.apply", json!({"name": "Proofs"}));
assert_eq!(g["settings"]["layout"]["grid"]["columns"], 3);
assert_eq!(g["settings"]["page"]["background"], json!([5, 6, 7]));
// refusals leave the templates as they were
let bad_file = dir.join("bad.lcprint");
std::fs::write(&bad_file, br#"{"format": "lightcraft.printTemplate", "version": 1, "name": "Bad", "settings": {"layout": {"pages": 0}}}"#)
.unwrap();
let huge = dir.join("huge.lcprint");
std::fs::write(&huge, vec![b' '; crate::print::template::MAX_FILE as usize + 1]).unwrap();
for (p, why) in [
(json!({}), "missing `path`"),
(json!({"path": " "}), "missing `path`"),
(json!({"path": dir.join("absent.lcprint").to_string_lossy()}), "absent.lcprint"),
(json!({"path": dir.to_string_lossy()}), "not a file"),
(json!({"path": huge.to_string_lossy()}), "too large"),
(json!({"path": bad_file.to_string_lossy()}), "template `Bad`"),
] {
let e = fails(&mut s, "print.template.import", p.clone());
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 2);
assert!(fails(&mut s, "print.template.export", json!({"name": "Nope", "path": dir.join("x").to_string_lossy()})).contains("no print template"));
assert!(fails(&mut s, "print.template.export", json!({"name": "Proofs"})).contains("missing `path`"));
assert!(fails(&mut s, "print.template.export", json!({"path": dir.join("x").to_string_lossy()})).contains("missing `name`"));
assert!(!dir.join("x.lcprint").exists());
// a full list takes no more
while s.print_templates.len() < crate::print::template::MAX_TEMPLATES {
let n = s.print_templates.len();
run(&mut s, "print.template.save", json!({"name": format!("T{n}")}));
}
assert!(fails(&mut s, "print.template.import", json!({"path": file.to_string_lossy()})).contains("at most"));
}
/// A library whose `prefs.json` can't be read, so saving the preferences fails.
#[derive(Clone, Default)]
struct PrefsUnreadable(MemStore);
impl lightcraft_catalog::Store for PrefsUnreadable {
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
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::<Vec<_>>();
let kept = names(&s);
let scratch = Scratch::new("unsaved");
let file = scratch.0.join("proofs.lcprint");
run(&mut s, "print.template.export", json!({"name": "Proofs", "path": file.to_string_lossy()}));
run(&mut s, "print.set", json!({"layout": {"style": "contactSheet"}}));
for (id, p) in [
("print.template.save", json!({"name": "Other"})),
("print.template.save", json!({"name": "proofs"})),
("print.template.delete", json!({"name": "Proofs"})),
("print.template.import", json!({"path": file.to_string_lossy()})),
] {
assert!(fails(&mut s, id, p.clone()).contains("prefs.json"), "{id} {p}");
assert_eq!(names(&s), kept, "{id} {p}");
}
}
#[test]
fn a_hostile_template_in_the_preferences_does_not_break_the_library() {
let files = MemStore::new();
@@ -364,6 +501,8 @@ fn every_print_command_is_registered_with_a_description() {
"print.template.save",
"print.template.apply",
"print.template.delete",
"print.template.export",
"print.template.import",
] {
let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing"));
assert!(c.params.len() > 3, "{id}");
+3
View File
@@ -47,6 +47,7 @@
"Add in place": "Přidat ze současného umístění",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Přidat ze současného umístění: knihovna odkazuje na soubory tam, kde jsou; nic se nekopíruje ani nepřesouvá.",
"Add keyword": "Přidat klíčové slovo",
"Add Keywords": "Přidat klíčová slova",
"Add number": "Přidat číslo",
"Add to": "Přidat do",
"Add to Album": "Přidat do alba",
@@ -1193,6 +1194,7 @@
"New time": "Nový čas",
"New…": "Nový…",
"Next Appearance Mode": "Další režim vzhledu",
"Next Keyword Set": "Další sada klíčových slov",
"Next Photo": "Další fotografie",
"Next up for AI Denoise…": "Další na řadě pro redukci šumu pomocí AI…",
"Night Blue": "Noční modř",
@@ -1402,6 +1404,7 @@
"Preview size": "Velikost náhledu",
"Preview unavailable": "Náhled není dostupný",
"Previews": "Náhledy",
"Previous Keyword Set": "Předchozí sada klíčových slov",
"Previous Photo": "Předchozí fotografie",
"Primaries (x, y)": "Primární barvy (x, y)",
"Print": "Tisk",
+3
View File
@@ -49,6 +49,7 @@
"Add in place": "Am Speicherort hinzufügen",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Am Speicherort hinzufügen: Die Bibliothek verweist auf die Dateien an ihrem aktuellen Speicherort; nichts wird kopiert oder verschoben.",
"Add keyword": "Stichwort hinzufügen",
"Add Keywords": "Stichwörter hinzufügen",
"Add number": "Nummer hinzufügen",
"Add to": "Hinzufügen zu",
"Add to Album": "Zu Album hinzufügen",
@@ -950,6 +951,7 @@
"New name": "Neuer Name",
"New time": "Neue Zeit",
"New…": "Neu…",
"Next Keyword Set": "Nächster Stichwortsatz",
"Next Photo": "Nächstes Foto",
"Night Blue": "Nachtblau",
"Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon-NEFs beginnen mit einem Farb- und Tonwertbild, das wie bei Sony-ARWs an das Kamera-JPEG angepasst ist, statt mit einem gedämpften,",
@@ -1112,6 +1114,7 @@
"Preview size": "Vorschaugröße",
"Preview unavailable": "Vorschau nicht verfügbar",
"Previews": "Vorschauen",
"Previous Keyword Set": "Vorheriger Stichwortsatz",
"Previous Photo": "Vorheriges Foto",
"Print": "Drucken",
"Print…": "Drucken…",
+3
View File
@@ -34,6 +34,7 @@
"Add in place": "Añadir sin mover",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Añadir sin mover: la biblioteca hace referencia a los archivos donde están; no se copia ni se mueve nada.",
"Add keyword": "Añadir palabra clave",
"Add Keywords": "Añadir palabras clave",
"Add number": "Añadir número",
"Add to": "Añadir a",
"Add to Album": "Añadir al álbum",
@@ -961,6 +962,7 @@
"New name": "Nuevo nombre",
"New time": "Nueva hora",
"New…": "Nuevo…",
"Next Keyword Set": "Conjunto de palabras clave siguiente",
"Next Photo": "Foto siguiente",
"Night Blue": "Azul nocturno",
"No": "No",
@@ -1117,6 +1119,7 @@
"Preview size": "Tamaño de la vista previa",
"Preview unavailable": "Vista previa no disponible",
"Previews": "Vistas previas",
"Previous Keyword Set": "Conjunto de palabras clave anterior",
"Previous Photo": "Foto anterior",
"Print": "Imprimir",
"Print…": "Imprimir…",
+3
View File
@@ -58,6 +58,7 @@
"Add in place": "Lisää paikalleen",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Lisää paikalleen: kirjasto viittaa tiedostoihin niiden nykyisessä sijainnissa; mitään ei kopioida tai siirretä.",
"Add keyword": "Lisää avainsana",
"Add Keywords": "Lisää avainsanoja",
"Add number": "Lisää numero",
"Add text overlay": "Lisää tekstipeittokuva",
"Add to": "Lisää kohteeseen",
@@ -1236,6 +1237,7 @@
"New…": "Uusi…",
"Next Appearance Mode": "Seuraava ulkoasutila",
"Next History Step": "Seuraava historiavaihe",
"Next Keyword Set": "Seuraava avainsanajoukko",
"Next Photo": "Seuraava kuva",
"Next Slide": "Seuraava dia",
"Next page": "Seuraava sivu",
@@ -1461,6 +1463,7 @@
"Preview unavailable": "Esikatselu ei käytettävissä",
"Previews": "Esikatselut",
"Previous History Step": "Edellinen historiavaihe",
"Previous Keyword Set": "Edellinen avainsanajoukko",
"Previous Photo": "Edellinen kuva",
"Previous Slide": "Edellinen dia",
"Previous page": "Edellinen sivu",
+3
View File
@@ -34,6 +34,7 @@
"Add in place": "Ajouter sur place",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Ajouter sur place : la bibliothèque référence les fichiers là où ils se trouvent ; rien n'est copié ni déplacé.",
"Add keyword": "Ajouter un mot-clé",
"Add Keywords": "Ajouter des mots-clés",
"Add number": "Ajouter un numéro",
"Add to": "Ajouter à",
"Add to Album": "Ajouter à l'album",
@@ -904,6 +905,7 @@
"New name": "Nouveau nom",
"New time": "Nouvelle heure",
"New…": "Nouveau…",
"Next Keyword Set": "Jeu de mots-clés suivant",
"Next Photo": "Photo suivante",
"Night Blue": "Bleu nuit",
"No": "Non",
@@ -1059,6 +1061,7 @@
"Preview size": "Taille de l'aperçu",
"Preview unavailable": "Aperçu indisponible",
"Previews": "Aperçus",
"Previous Keyword Set": "Jeu de mots-clés précédent",
"Previous Photo": "Photo précédente",
"Print": "Imprimer",
"Print…": "Imprimer…",
+3
View File
@@ -58,6 +58,7 @@
"Add in place": "Tambahkan di lokasi asli",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Tambahkan di lokasi asli: pustaka merujuk file di lokasinya saat ini; tidak ada yang disalin atau dipindahkan.",
"Add keyword": "Tambahkan kata kunci",
"Add Keywords": "Tambahkan kata kunci",
"Add number": "Tambahkan nomor",
"Add text overlay": "Tambahkan overlay teks",
"Add to": "Tambahkan ke",
@@ -1233,6 +1234,7 @@
"New…": "Baru…",
"Next Appearance Mode": "Mode tampilan berikutnya",
"Next History Step": "Langkah riwayat berikutnya",
"Next Keyword Set": "Set kata kunci berikutnya",
"Next Photo": "Foto berikutnya",
"Next Slide": "Slide berikutnya",
"Next up for AI Denoise…": "Berikutnya dalam antrean pengurangan noise AI…",
@@ -1445,6 +1447,7 @@
"Preview unavailable": "Pratinjau tidak tersedia",
"Previews": "Pratinjau",
"Previous History Step": "Langkah riwayat sebelumnya",
"Previous Keyword Set": "Set kata kunci sebelumnya",
"Previous Photo": "Foto sebelumnya",
"Previous Slide": "Slide sebelumnya",
"Previous settings": "Pengaturan sebelumnya",
+5
View File
@@ -34,6 +34,7 @@
"Add in place": "Aggiungi sul posto",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.",
"Add keyword": "Aggiungi parola chiave",
"Add Keywords": "Aggiungi parole chiave",
"Add number": "Aggiungi numero",
"Add to": "Aggiungi a",
"Add to Album": "Aggiungi all'album",
@@ -907,6 +908,7 @@
"New name": "Nuovo nome",
"New time": "Nuova ora",
"New…": "Nuovo…",
"Next Keyword Set": "Set di parole chiave successivo",
"Next Photo": "Foto successiva",
"Night Blue": "Blu notte",
"No": "No",
@@ -1068,6 +1070,7 @@
"Preview size": "Dimensione dell'anteprima",
"Preview unavailable": "Anteprima non disponibile",
"Previews": "Anteprime",
"Previous Keyword Set": "Set di parole chiave precedente",
"Previous Photo": "Foto precedente",
"Print": "Stampa",
"Print…": "Stampa…",
@@ -2333,9 +2336,11 @@
"View Options": "Opzioni di visualizzazione",
"Loupe View": "Vista lente",
"Show Info Overlay": "Mostra sovrapposizione informazioni",
"Show message when loading or rendering photos": "Mostra un messaggio durante il caricamento o il rendering delle foto",
"Loupe Info 1": "Info lente 1",
"Loupe Info 2": "Info lente 2",
"Use Defaults": "Usa predefiniti",
"Show briefly when photo changes": "Mostra brevemente al cambio di foto",
"Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.",
"Before/After Side by Side": "Prima/Dopo affiancati",
+3
View File
@@ -35,6 +35,7 @@
"Add in place": "現在の場所から追加",
"Add in place: the library references the files where they are; nothing is copied or moved.": "現在の場所のファイルを参照します。コピーや移動はしません",
"Add keyword": "キーワードを追加",
"Add Keywords": "キーワードを追加",
"Add number": "番号を付ける",
"Add to": "追加先",
"Add to Album": "アルバムに追加",
@@ -632,6 +633,7 @@
"Nested group": "入れ子のグループ",
"New Album": "新しいアルバム",
"New time": "新しい日時",
"Next Keyword Set": "次のキーワードセット",
"Next Photo": "次の写真",
"Night Blue": "ナイトブルー",
"No contributor data was built into this copy.": "このビルドにはコントリビューターのデータが含まれていません",
@@ -672,6 +674,7 @@
"Preview Build Progress": "プレビュー作成の進行状況",
"Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中",
"Preview unavailable": "プレビューを表示できません",
"Previous Keyword Set": "前のキーワードセット",
"Previous Photo": "前の写真",
"Profile Browser": "プロファイルブラウザー",
"Profile Menu": "プロファイルメニュー",
+3
View File
@@ -110,6 +110,7 @@
"Add in place": "ადგილზე დამატება",
"Add in place: the library references the files where they are; nothing is copied or moved.": "ადგილზე დამატება: ბიბლიოთეკა ფაილებს მათ ამჟამინდელ ადგილას მიმართავს; არაფერი კოპირდება ან გადაიტანება.",
"Add keyword": "საკვანძო სიტყვის დამატება",
"Add Keywords": "საკვანძო სიტყვების დამატება",
"Add number": "რიცხვის დამატება",
"Add to": "დამატება:",
"Add to Album": "ალბომში დამატება",
@@ -1310,6 +1311,7 @@
"New…": "ახალი…",
"Next Appearance Mode": "შემდეგი იერსახის რეჟიმი",
"Next History Step": "ისტორიის შემდეგი ნაბიჯი",
"Next Keyword Set": "საკვანძო სიტყვების შემდეგი ნაკრები",
"Next Photo": "შემდეგი ფოტო",
"Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…",
"Night Blue": "ღამის ლურჯი",
@@ -1534,6 +1536,7 @@
"Previews": "წინასწარი ხედები",
"Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე",
"Previous History Step": "ისტორიის წინა ნაბიჯი",
"Previous Keyword Set": "საკვანძო სიტყვების წინა ნაკრები",
"Previous Photo": "წინა ფოტო",
"Primaries (x, y)": "ძირითადი ფერები (x, y)",
"Print": "ბეჭდვა",
+3
View File
@@ -31,6 +31,7 @@
"Add in place": "Dodaj bez kopiowania plików",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.",
"Add keyword": "Dodaj słowo kluczowe",
"Add Keywords": "Dodaj słowa kluczowe",
"Add number": "Dodaj numer",
"Add to": "Dodaj do",
"Add to Album": "Dodaj do albumu",
@@ -886,6 +887,7 @@
"New name": "Nowa nazwa",
"New time": "Nowy czas",
"New…": "Nowy…",
"Next Keyword Set": "Następny zestaw słów kluczowych",
"Next Photo": "Następne zdjęcie",
"Night Blue": "Nocny błękit",
"No": "Nie",
@@ -1030,6 +1032,7 @@
"Preview only": "Tylko podgląd",
"Preview size": "Rozmiar podglądu",
"Previews": "Podglądy",
"Previous Keyword Set": "Poprzedni zestaw słów kluczowych",
"Previous Photo": "Poprzednie zdjęcie",
"Print": "Drukuj",
"Print…": "Drukuj…",
+3
View File
@@ -36,6 +36,7 @@
"Add in place": "Adicionar no local",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Adicionar no local: a biblioteca referencia os arquivos onde eles estão; nada é copiado nem movido.",
"Add keyword": "Adicionar palavra-chave",
"Add Keywords": "Adicionar palavras-chave",
"Add number": "Adicionar número",
"Add to": "Adicionar a",
"Add to Album": "Adicionar ao álbum",
@@ -916,6 +917,7 @@
"New name": "Novo nome",
"New time": "Novo horário",
"New…": "Novo…",
"Next Keyword Set": "Próximo conjunto de palavras-chave",
"Next Photo": "Próxima foto",
"Night Blue": "Azul noturno",
"No": "Não",
@@ -1077,6 +1079,7 @@
"Preview size": "Tamanho da pré-visualização",
"Preview unavailable": "Prévia indisponível",
"Previews": "Pré-visualizações",
"Previous Keyword Set": "Conjunto de palavras-chave anterior",
"Previous Photo": "Foto anterior",
"Print": "Imprimir",
"Print…": "Imprimir…",
+3
View File
@@ -20,6 +20,7 @@
"Add in place": "Добавить на месте",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Добавить на месте: библиотека ссылается на файлы там, где они лежат; ничего не копируется и не перемещается.",
"Add keyword": "Добавить ключевое слово",
"Add Keywords": "Добавить ключевые слова",
"Add number": "Добавить номер",
"Add to": "Добавить в",
"Add to Album": "Добавить в альбом",
@@ -760,6 +761,7 @@
"New blank PDF": "Новый пустой PDF",
"New name": "Новое имя",
"New…": "Новый…",
"Next Keyword Set": "Следующий набор ключевых слов",
"Next Photo": "Следующее фото",
"Night Blue": "Ночной синий",
"No": "Нет",
@@ -897,6 +899,7 @@
"Preview size": "Размер просмотра",
"Preview unavailable": "Просмотр недоступен",
"Previews": "Просмотры",
"Previous Keyword Set": "Предыдущий набор ключевых слов",
"Previous Photo": "Предыдущее фото",
"Print": "Печать",
"Print…": "Печать…",
+3
View File
@@ -60,6 +60,7 @@
"Add in place": "Lägg till på plats",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Lägg till på plats: biblioteket refererar till filerna där de ligger; ingenting kopieras eller flyttas.",
"Add keyword": "Lägg till nyckelord",
"Add Keywords": "Lägg till nyckelord",
"Add Mask": "Lägg till mask",
"Add number": "Lägg till nummer",
"Add Object Click": "Lägg till objektklick",
@@ -1362,6 +1363,7 @@
"New…": "Ny…",
"Next Appearance Mode": "Nästa utseendeläge",
"Next page": "Nästa sida",
"Next Keyword Set": "Nästa nyckelordsuppsättning",
"Next Photo": "Nästa foto",
"Next up for AI Denoise…": "Näst i tur för AI-brusreducering…",
"Night Blue": "Nattblå",
@@ -1599,6 +1601,7 @@
"Previews": "Förhandsgranskningar",
"previews": "förhandsgranskningar",
"Previous page": "Föregående sida",
"Previous Keyword Set": "Föregående nyckelordsuppsättning",
"Previous Photo": "Föregående foto",
"Previous settings": "Föregående inställningar",
"Primaries (x, y)": "Primärfärger (x, y)",
+3
View File
@@ -389,6 +389,7 @@
"Add in place": "Додати на місці",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Додати на місці: бібліотека посилається на файли в їхньому розташуванні; нічого не копіюється й не переміщується.",
"Add keyword": "Додати ключове слово",
"Add Keywords": "Додати ключові слова",
"Add number": "Додати число",
"Add to Album": "Додати до альбому",
"Add to Favorites": "Додати до улюблених",
@@ -855,6 +856,7 @@
"Nested group": "Вкладена група",
"New Album": "Новий альбом",
"New time": "Новий час",
"Next Keyword Set": "Наступний набір ключових слів",
"Next Photo": "Наступне фото",
"Night Blue": "Нічна синява",
"No named people in these photos": "На цих фото немає людей з іменами",
@@ -887,6 +889,7 @@
"Presets and profiles": "Набори налаштувань і профілі",
"Preview Build Progress": "Перебіг створення переглядів",
"Preview only: editing the camera's embedded JPEG": "Лише перегляд: редагування вбудованого JPEG камери",
"Previous Keyword Set": "Попередній набір ключових слів",
"Previous Photo": "Попереднє фото",
"Profile Browser": "Оглядач профілів",
"Profile Menu": "Меню профілів",
+3
View File
@@ -60,6 +60,7 @@
"Add in place": "Thêm tại chỗ",
"Add in place: the library references the files where they are; nothing is copied or moved.": "Thêm tại chỗ: thư viện tham chiếu tệp ở vị trí hiện tại; không sao chép hay di chuyển gì.",
"Add keyword": "Thêm từ khóa",
"Add Keywords": "Thêm từ khóa",
"Add number": "Thêm số",
"Add to": "Thêm vào",
"Add to Album": "Thêm vào album",
@@ -1136,6 +1137,7 @@
"New…": "Mới…",
"Next Appearance Mode": "Chế độ giao diện tiếp theo",
"Next History Step": "Bước lịch sử sau",
"Next Keyword Set": "Bộ từ khóa tiếp theo",
"Next Photo": "Ảnh tiếp theo",
"Next up for AI Denoise…": "Tiếp theo cho AI Khử nhiễu…",
"Night Blue": "Xanh đêm",
@@ -1338,6 +1340,7 @@
"Preview unavailable": "Không có xem trước",
"Previews": "Xem trước",
"Previous History Step": "Bước lịch sử trước",
"Previous Keyword Set": "Bộ từ khóa trước",
"Previous Photo": "Ảnh trước",
"Primaries (x, y)": "Màu gốc (x, y)",
"Print": "In",
+3
View File
@@ -36,6 +36,7 @@
"Add in place": "在原位置添加",
"Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。",
"Add keyword": "添加关键字",
"Add Keywords": "添加关键字",
"Add number": "添加编号",
"Add to": "添加到",
"Add to Album": "添加到相册",
@@ -981,6 +982,7 @@
"New blank PDF": "新建空白 PDF",
"New name": "新名称",
"New…": "新建…",
"Next Keyword Set": "下一个关键字集",
"Next Photo": "下一张照片",
"Night Blue": "夜色蓝调",
"No": "否",
@@ -1109,6 +1111,7 @@
"Preview only": "仅预览",
"Preview size": "预览大小",
"Previews": "预览",
"Previous Keyword Set": "上一个关键字集",
"Previous Photo": "上一张照片",
"Print": "打印",
"Print…": "打印…",
+3
View File
@@ -36,6 +36,7 @@
"Add in place": "原地加入",
"Add in place: the library references the files where they are; nothing is copied or moved.": "原地加入:照片庫參照檔案目前的位置,不會複製或移動檔案。",
"Add keyword": "新增關鍵字",
"Add Keywords": "新增關鍵字",
"Add number": "加上編號",
"Add to": "加入至",
"Add to Album": "加入相簿",
@@ -982,6 +983,7 @@
"New blank PDF": "新增空白 PDF",
"New name": "新名稱",
"New…": "新增…",
"Next Keyword Set": "下一個關鍵字集",
"Next Photo": "下一張照片",
"Night Blue": "夜色藍調",
"No": "否",
@@ -1110,6 +1112,7 @@
"Preview only": "僅供預覽",
"Preview size": "預覽尺寸",
"Previews": "預覽",
"Previous Keyword Set": "上一個關鍵字集",
"Previous Photo": "上一張照片",
"Print": "列印",
"Print…": "列印…",
+364
View File
@@ -2926,6 +2926,260 @@ mod tests {
assert_eq!(meta(&h).title, "Wedding in Sintra");
}
/// With several photos selected the Info panel offers Target Photo / Selected Photos. Target
/// Photo (the default) shows the active photo's values. Selected Photos leaves a field whose
/// values differ empty, so what is typed there is what every selected photo gets, and keeps
/// the value of a field they share; leaving a mixed field untouched saves nothing.
#[test]
fn info_shows_metadata_for_the_selected_photos() {
let mut h = demo([1300.0, 1000.0]);
let t = Duration::from_secs(10);
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
let r = h.request(method, params.clone(), t);
assert_eq!(r["ok"], true, "{method} {params}: {r}");
h.step();
h.step();
r
};
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
ask(h, "engine.execute", json!({"command": command, "params": params}))["result"].clone()
};
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
let (a, b) = (lightcraft_catalog::PhotoId(ids[0]), lightcraft_catalog::PhotoId(ids[1]));
let meta = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.clone();
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn", "copyrightStatus": "copyrighted"}));
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "title": "Dusk"}));
exec(&mut h, "photo.setMeta", json!({"ids": ids, "creator": "Ana"}));
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]}));
h.settle(SETTLE);
let buttons = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "infoFor"}), t)["result"].to_string();
assert!(buttons(&mut h).contains("button:infoFor-selected"), "the choice shows with two photos selected");
assert!(h.app.ui.settings.info_selected_photos, "Selected Photos by default");
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "title": "Dawn!"}));
// Target Photo and back to Selected Photos, from their buttons
ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-target"}));
assert!(!h.app.ui.settings.info_selected_photos);
ask(&mut h, "ui.clickWidget", json!({"id": "button:infoFor-selected"}));
assert!(h.app.ui.settings.info_selected_photos);
// a mixed field left untouched saves nothing
let undo = h.app.session.undo.len();
ask(&mut h, "ui.clickWidget", json!({"id": "field:title"}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo));
// neither does switching the choice while a field is being edited, either way
for on in [false, true] {
ask(&mut h, "ui.clickWidget", json!({"id": "field:title"}));
exec(&mut h, "view.infoSelectedPhotos", json!({"on": on}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
assert_eq!((meta(&h, a).title, meta(&h, b).title, h.app.session.undo.len()), ("Dawn!".into(), "Dusk".into(), undo), "{on}");
}
// the mixed title starts empty; the shared creator keeps its value
for (key, typed, want) in [("title", "Harbor", "Harbor"), ("creator", " Silva", "Ana Silva")] {
ask(&mut h, "ui.clickWidget", json!({"id": format!("field:{key}")}));
ask(&mut h, "ui.key", json!({"key": "End"}));
ask(&mut h, "ui.text", json!({"text": typed}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
let got = |h: &Headless, id| if key == "title" { meta(h, id).title } else { meta(h, id).creator };
assert_eq!((got(&h, a), got(&h, b)), (want.to_string(), want.to_string()), "{key}");
}
// a mixed field typed in and left empty clears it on every selected photo
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Old"}));
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Other"}));
h.settle(SETTLE);
ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"}));
ask(&mut h, "ui.text", json!({"text": "x"}));
ask(&mut h, "ui.key", json!({"key": "Backspace"}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
assert_eq!((meta(&h, a).caption, meta(&h, b).caption), (String::new(), String::new()));
// GPS: one photo's place and Show on Map only while the selected photos share it
let map = |h: &mut Headless| h.request("ui.widgets", json!({"filter": "showOnMap"}), t)["result"].to_string().contains("showOnMap");
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "gps": [41.15, -8.61]}));
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "gps": [38.72, -9.14]}));
h.settle(SETTLE);
assert!(!map(&mut h), "places differ");
exec(&mut h, "photo.setMeta", json!({"ids": ids, "gps": [41.15, -8.61]}));
h.settle(SETTLE);
assert!(map(&mut h), "a shared place");
// the command: refuses a value that isn't true or false, and turns Target Photo back on
let r = h.request("engine.execute", json!({"command": "view.infoSelectedPhotos", "params": {"on": "yes"}}), t);
assert_ne!(r["ok"], true, "{r}");
assert!(h.app.ui.settings.info_selected_photos);
assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}))["on"], false);
assert!(!h.app.ui.settings.info_selected_photos);
assert_eq!(exec(&mut h, "view.infoSelectedPhotos", json!({}))["on"], true, "no params toggles");
// the choice is saved with the app settings; settings saved before it read as Selected Photos
exec(&mut h, "view.infoSelectedPhotos", json!({"on": false}));
let saved = serde_json::to_value(&h.app.ui.settings).unwrap();
assert_eq!(saved["infoSelectedPhotos"], false);
let read: crate::state::AppSettings = serde_json::from_value(saved).unwrap();
assert!(!read.info_selected_photos);
let old: crate::state::AppSettings = serde_json::from_value(json!({"filmNames": false})).unwrap();
assert!(old.info_selected_photos);
// one photo selected: no choice to make
exec(&mut h, "library.select", json!({"ids": [ids[0]]}));
h.settle(SETTLE);
assert!(!buttons(&mut h).contains("infoFor-"), "{}", buttons(&mut h));
}
/// With several photos selected and the settings as they come, an edit to a field where the
/// photos differ starts from an empty field: the photos get what was typed, not the active
/// photo's value with the typing added.
#[test]
fn info_edits_of_a_multi_selection_start_from_mixed_fields_by_default() {
let mut h = demo([1300.0, 1000.0]);
let t = Duration::from_secs(10);
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
let r = h.request(method, params.clone(), t);
assert_eq!(r["ok"], true, "{method} {params}: {r}");
h.step();
h.step();
};
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
ask(h, "engine.execute", json!({"command": command, "params": params}));
};
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
let caption = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.caption.clone();
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "caption": "Dawn"}));
exec(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "caption": "Dusk"}));
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
exec(&mut h, "library.select", json!({"ids": ids, "active": ids[0]}));
h.settle(SETTLE);
ask(&mut h, "ui.clickWidget", json!({"id": "field:caption"}));
ask(&mut h, "ui.key", json!({"key": "End"}));
ask(&mut h, "ui.text", json!({"text": "Harbor"}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
assert_eq!((caption(&h, 0), caption(&h, 1)), ("Harbor".into(), "Harbor".into()));
}
/// The fields the Info panel marks as mixed: each text field, the copyright status and GPS
/// where a selected photo differs from the photo the panel shows, and none for one photo.
#[test]
fn info_mixed_fields_are_the_ones_that_differ() {
let mut s = lightcraft_engine::Session::with_demo();
let ids: Vec<u64> = 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<lightcraft_catalog::PhotoId> = ids.iter().map(|&i| lightcraft_catalog::PhotoId(i)).collect();
let shown = s.catalog.photo(photos[0]).unwrap().meta.clone();
assert!(crate::panels::right::mixed_meta(&s.catalog, &photos, &shown).is_empty());
assert!(crate::panels::right::mixed_meta(&s.catalog, &photos[..1], &shown).is_empty());
s.execute("photo.setMeta", &json!({"ids": [ids[2]], "city": "Lisbon", "caption": "x", "gps": [38.72, -9.14]})).unwrap();
s.execute("photo.setMeta", &json!({"ids": [ids[1]], "copyrightStatus": "publicDomain"})).unwrap();
let mut mixed = crate::panels::right::mixed_meta(&s.catalog, &photos, &shown);
mixed.sort_unstable();
assert_eq!(mixed, ["caption", "city", "copyrightStatus", "gps"]);
}
/// Selected Photos with the active photo outside the selection: the fields show what the
/// selected photos share, not the active photo's values.
#[test]
fn info_selected_photos_ignore_an_active_photo_outside_the_selection() {
let mut h = demo([1300.0, 1000.0]);
let t = Duration::from_secs(10);
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
let r = h.request(method, params.clone(), t);
assert_eq!(r["ok"], true, "{method} {params}: {r}");
h.step();
h.step();
};
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
ask(h, "engine.execute", json!({"command": command, "params": params}));
};
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect();
let title = |h: &Headless, i: usize| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(ids[i])).unwrap().meta.title.clone();
exec(&mut h, "photo.setMeta", json!({"ids": [ids[0], ids[1]], "title": "Same"}));
exec(&mut h, "photo.setMeta", json!({"ids": [ids[2]], "title": "Other"}));
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[2]}));
exec(&mut h, "view.infoSelectedPhotos", json!({"on": true}));
h.settle(SETTLE);
ask(&mut h, "ui.clickWidget", json!({"id": "field:title"}));
ask(&mut h, "ui.key", json!({"key": "End"}));
ask(&mut h, "ui.text", json!({"text": "!"}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
assert_eq!((title(&h, 0), title(&h, 1), title(&h, 2)), ("Same!".into(), "Same!".into(), "Other".into()));
}
/// An Info field saves to the photos selected when its edit started, also when the selection
/// changes while the field is being edited.
#[test]
fn info_field_saves_to_the_photos_selected_when_the_edit_started() {
// (tall enough for the GPS field to show without scrolling)
let mut h = demo([1300.0, 1800.0]);
let t = Duration::from_secs(10);
let ask = |h: &mut Headless, method: &str, params: serde_json::Value| {
let r = h.request(method, params.clone(), t);
assert_eq!(r["ok"], true, "{method} {params}: {r}");
h.step();
h.step();
};
let exec = |h: &mut Headless, command: &str, params: serde_json::Value| {
ask(h, "engine.execute", json!({"command": command, "params": params}));
};
let ids: Vec<u64> = 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<bool> = (0..4).map(|i| meta(&h, i).gps.is_some()).collect();
assert_eq!(gps, [true, true, false, false]);
// Esc gives a GPS edit up
exec(&mut h, "library.select", json!({"ids": [ids[0], ids[1]], "active": ids[0]}));
h.settle(SETTLE);
let before = meta(&h, 0).gps;
ask(&mut h, "ui.clickWidget", json!({"id": "field:gps"}));
ask(&mut h, "ui.key", json!({"key": "End"}));
ask(&mut h, "ui.text", json!({"text": "5"}));
ask(&mut h, "ui.key", json!({"key": "Escape"}));
ask(&mut h, "ui.key", json!({"key": "Tab"}));
h.settle(SETTLE);
assert_eq!((meta(&h, 0).gps, meta(&h, 1).gps), (before, before));
}
/// Local: a folder's photos show without joining the library; the breadcrumb, Include
/// subfolders and Add to My Photos work from the grid header.
#[test]
@@ -4634,6 +4888,53 @@ mod tests {
assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing");
}
/// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one
/// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows
/// the chosen Info after → changes the photo, and hides it again a few seconds later.
#[test]
fn show_briefly_when_photo_changes_in_view_options() {
use crate::state::{BRIEF_INFO_SECS, InfoOverlay};
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true);
h.step();
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true);
h.step();
assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure);
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true);
h.step();
assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most");
h.app.ui.dialog = None;
h.step();
let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay");
assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off);
h.time += BRIEF_INFO_SECS + 1.0;
h.step();
h.step();
assert!(!overlay(&h), "hidden once the time has passed");
let before = h.app.session.active();
h.request("ui.key", json!({"key": "right"}), t);
assert_ne!(h.app.session.active(), before);
h.step();
assert!(overlay(&h), "shown after → changes the photo");
h.time += BRIEF_INFO_SECS + 1.0;
h.step();
h.step();
assert!(!overlay(&h), "and hidden again");
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}");
h.request("ui.key", json!({"key": "right"}), t);
h.step();
assert!(!overlay(&h), "off: nothing shows when the photo changes");
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t);
assert_eq!(r["ok"], false, "{r}");
assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing");
}
/// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the
/// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit.
#[test]
@@ -4970,6 +5271,69 @@ mod tests {
assert!(!h.app.renderer.is_pending(Slot::Main));
}
/// View Options' Show message when loading or rendering photos (on by default): turned off in
/// the window, the loupe does not say "Rendering…" while an edit renders; `view.viewOptions
/// {loupeMessages}` turns it back on for an agent (refusing a value that is not true or false).
#[test]
fn view_options_turn_the_rendering_message_off() {
use crate::render::Slot;
use std::{cell::Cell, rc::Rc};
let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering");
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert!(h.app.ui.settings.loupe_messages, "on by default");
assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true);
h.step();
assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.loupeMessages"}), t)["ok"], true);
h.step();
assert!(!h.app.ui.settings.loupe_messages, "the tick box turns it off");
h.app.ui.dialog = None;
let open = Rc::new(Cell::new(false));
h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone())));
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t);
for _ in 0..45 {
h.step();
}
assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held");
assert!(!pill(&h), "no message while it is off");
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": "yes"}}), t);
assert_eq!(r["ok"], false, "{r}");
assert!(!h.app.ui.settings.loupe_messages, "a refused change changes nothing");
let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"loupeMessages": true}}), t);
assert_eq!(r["result"]["loupeMessages"], true, "{r}");
assert_eq!(h.app.ui.dialog, None, "a value sets it without opening the window");
assert!(h.step_until(SETTLE, pill), "the message shows again while the render is held");
open.set(true);
assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands");
}
/// With nothing to show yet, the loupe says "Rendering…" in its middle only while View Options'
/// Show message when loading or rendering photos is on.
#[test]
fn an_empty_loupe_says_rendering_only_with_the_message_on() {
use std::{cell::Cell, rc::Rc};
for on in [true, false] {
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.app.ui.settings.loupe_messages = on;
h.app.renderer.set_offload(Box::new(super::RenderGate::new(Rc::new(Cell::new(false)))));
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
// drop every texture (quick previews and thumbnails land outside the gate) until a
// frame has nothing to show
let empty = (0..60).any(|_| {
h.app.renderer.textures.clear();
h.step();
h.app.loupe_shown.map(|l| l.1) == Some("none")
});
assert!(empty, "a frame with nothing rendered");
let said = h.painted_text().iter().any(|text| text == "Rendering…");
assert_eq!(said, on, "message on: {on}");
}
}
/// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left
/// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the
/// error on hover, and keeps saying so while a library change retries it.
+4
View File
@@ -300,6 +300,10 @@ pub struct Services {
pub pick_slideshow_templates: Option<PickFiles>,
/// Save dialog for an exported slideshow template (`.json`).
pub save_slideshow_template: Option<SaveFile>,
/// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…).
pub pick_print_templates: Option<PickFiles>,
/// Save dialog for an exported print template (`.lcprint`).
pub save_print_template: Option<SaveFile>,
pub write: Option<WriteFn>,
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
pub write_shared: Option<SharedWrite>,
+46 -2
View File
@@ -139,6 +139,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("local.addRoot", "Add Folder to Local", None, ""),
("local.hide", "Remove from Local", None, ""),
("local.restoreHidden", "Show Hidden Local Locations", None, ""),
// {on?}: the Info panel's Target Photo / Selected Photos choice (on = Selected Photos)
("view.infoSelectedPhotos", "Show Metadata for Selected Photos", None, ""),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
("view.lightsOut", "Lights Out", Some("Shift+L"), "View"),
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
@@ -158,6 +160,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("panel.presets", "Presets", Some("Shift+P"), "Window"),
("panel.info", "Info", Some("I"), "Window"),
("panel.keywords", "Keywords", Some("K"), "Window"),
// the Keywords panel with the cursor in its Add keyword box
("panel.addKeywords", "Add Keywords", Some("Cmd+K"), ""),
("panel.versions", "Versions", Some("Shift+V"), "Window"),
("panel.activity", "History", None, "Window"),
("panel.close", "Close Panel", None, ""),
@@ -749,9 +753,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"infoOverlay": app.ui.info_overlay}))
}
"view.viewOptions" => {
// `info1` / `info2` set the lines for an agent; without them the window opens
// `info1` / `info2` set the lines, `briefly` the Info that Show Briefly When Photo Changes
// shows and `loupeMessages` Show message when loading or rendering photos, for an agent;
// without any of them the window opens
let mut info = app.ui.settings.loupe_info;
let mut set = false;
// `loupeMessages` sets Show message when loading or rendering photos
let messages = match p.get("loupeMessages") {
None => app.ui.settings.loupe_messages,
Some(v) => match v.as_bool() {
Some(on) => {
set = true;
on
}
None => return Some(Err("`loupeMessages`: true|false".into())),
},
};
for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] {
let Some(v) = p.get(key) else { continue };
match serde_json::from_value(v.clone()) {
@@ -762,12 +779,20 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
set = true;
}
if let Some(v) = p.get("briefly") {
match serde_json::from_value(v.clone()) {
Ok(v) => info.briefly = v,
Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())),
}
set = true;
}
if set {
app.ui.settings.loupe_info = info;
app.ui.settings.loupe_messages = messages;
} else {
app.ui.dialog = Some(Dialog::ViewOptions);
}
Ok(json!({"loupeInfo": app.ui.settings.loupe_info}))
Ok(json!({"loupeInfo": app.ui.settings.loupe_info, "loupeMessages": app.ui.settings.loupe_messages}))
}
"view.chooseLayoutImage" => {
let path = match p.get("path").and_then(Value::as_str) {
@@ -1114,6 +1139,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins);
Ok(json!({"maskPins": app.ui.mask_pins}))
}
"view.infoSelectedPhotos" => {
// {on?}; no params toggles. On: the Info panel shows what the selected photos share.
let on = match p.get("on") {
None | Some(Value::Null) => !app.ui.settings.info_selected_photos,
Some(Value::Bool(on)) => *on,
Some(v) => return Some(Err(format!("view.infoSelectedPhotos: `on` must be true or false, not {v}"))),
};
app.ui.settings.info_selected_photos = on;
Ok(json!({"on": on}))
}
"view.toolOverlay" => {
use crate::state::ToolOverlay;
let mode = match p.get("mode") {
@@ -1242,6 +1277,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
panel(app, &ctx, RightPanel::Keywords, "Keywords");
Ok(Value::Null)
}
"panel.addKeywords" => {
// 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)
+61 -3
View File
@@ -361,6 +361,25 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
r
}
/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and
/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a
/// repaint is asked for then, so it hides without input).
///
/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS
fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) {
let now = ctx.input(|i| i.time);
let brief = &mut app.ui.brief_info;
if brief.photo != Some(id) {
brief.photo = Some(id);
brief.since = now;
}
let left = crate::state::BRIEF_INFO_SECS - (now - brief.since);
brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0;
if brief.shown {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(left));
}
}
/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all)
/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize
/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high
@@ -499,6 +518,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
return;
};
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
brief_info(app, ui.ctx(), id);
let d = (*photo.develop).clone();
// the full-screen preview shows the photo only: no tool overlays
let right = if fullscreen { RightPanel::None } else { app.ui.right };
@@ -824,9 +844,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
);
p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim);
}
None => {
None if app.ui.settings.loupe_messages => {
p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim);
}
None => {}
}
}
}
@@ -978,9 +999,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let over = if over.is_positive() { over } else { canvas };
match failed {
Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e),
None => {
// View Options' Show message when loading or rendering photos
None if app.ui.settings.loupe_messages => {
loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false);
}
None => {}
}
}
if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) {
@@ -1062,7 +1085,10 @@ fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 {
/// The info overlay's lines for `photo`, or `None` when it is off.
fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option<Vec<String>> {
use crate::state::InfoOverlay;
let fields = match app.ui.info_overlay {
// with the overlay off, Show Briefly When Photo Changes shows its Info for a moment
let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown;
let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay };
let fields = match mode {
InfoOverlay::Off => return None,
InfoOverlay::Basic => &app.ui.settings.loupe_info.info1,
InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2,
@@ -3528,6 +3554,38 @@ mod info_overlay_tests {
assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]);
}
/// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows
/// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays.
#[test]
fn show_briefly_shows_the_info_after_the_photo_changes() {
use crate::state::{BRIEF_INFO_SECS, InfoOverlay};
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
let photos: Vec<lightcraft_catalog::Photo> = 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<String> {
let ctx = egui::Context::default();
+2 -1
View File
@@ -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();
+180 -32
View File
@@ -20,6 +20,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let Some(id) = app.session.active() else {
// the Keyword List is the library's, not a photo's: it is there without a selection
if app.ui.right == RightPanel::Keywords {
// (without a selection there is no Add keyword box for Add Keywords to put the cursor in)
app.ui.focus_keywords = false;
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
if fold_header(app, ui, "keywording", "Keywords") {
@@ -674,8 +676,17 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
});
divider(ui);
let m = p.meta.clone();
// with several photos selected: Target Photo shows this photo's values, Selected Photos what
// the selection shares (a field where they differ is empty and says Mixed)
let selected = app.session.selected();
let several = selected.len() > 1;
let for_selected = several && app.ui.settings.info_selected_photos;
let m = shown_meta(&app.session.catalog, &p, &selected, for_selected);
let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() };
padded(ui, |ui| {
if several {
metadata_for(app, ui);
}
for (label, key, value, lines) in [
("Title", "title", &m.title, 1),
("Caption", "caption", &m.caption, 2),
@@ -683,15 +694,15 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("Extended Description", "extendedDescription", &m.extended_description, 3),
("Copyright", "copyright", &m.copyright, 1),
] {
meta_field(app, ui, label, key, value, lines);
meta_field(app, ui, label, key, value, lines, mixed.contains(&key));
}
copyright_status(app, ui, m.copyright_status);
copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"));
for (label, key, value, lines) in [
("Rights Usage Terms", "usageTerms", &m.usage_terms, 2),
("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1),
("Creator", "creator", &m.creator, 1),
] {
meta_field(app, ui, label, key, value, lines);
meta_field(app, ui, label, key, value, lines, mixed.contains(&key));
}
// file: name (rename), path (reveal), capture time (edit)
let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim));
@@ -740,28 +751,32 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("State / Province", "state", &m.state),
("Country", "country", &m.country),
] {
meta_field(app, ui, label, key, value, 1);
meta_field(app, ui, label, key, value, 1, mixed.contains(&key));
}
// GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it
let gps = m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default();
let gps_mixed = mixed.contains(&"gps");
let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() };
let gid = egui::Id::new("info-gps");
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY));
crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS");
register(ui.ctx(), "field:gps", r.rect);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(gid, text.clone()));
let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &selected));
let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" });
let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui);
edit.typed |= edit.text != edit.start;
if r.editing {
ui.data_mut(|d| d.insert_temp(gid, edit.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(gid));
ui.data_mut(|d| d.remove::<FieldEdit>(gid));
}
if r.lost_focus()
&& text.trim() != gps
&& let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()}))
// Return or leaving saves; Esc gives the edit up
let text = edit.text.trim();
if r.committed()
&& (text != edit.start || edit.clears())
&& let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text}))
{
app.toast(ui.ctx(), e);
}
if let Some((la, lo)) = m.gps {
// (one photo's place: not shown while the selected photos' places differ)
if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) {
let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" });
ui.horizontal(|ui| {
ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim));
@@ -896,15 +911,136 @@ fn human_size(bytes: u64) -> String {
}
}
/// A labelled metadata text field: the typed text lives in egui memory while focused and is
/// saved (photo.setMeta `key`) when the field loses focus.
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`).
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) {
/// What a metadata field shows while the selected photos' values differ (Selected Photos).
const MIXED: &str = "Mixed";
/// An Info panel field's edit under way, kept in egui memory between frames. A field saves when
/// its text is not the text the edit started from, so a field left as it was saves nothing, even
/// when what the panel shows changed during the edit. It saves to the photos selected when the
/// edit started, also when the selection changed since.
#[derive(Clone)]
struct FieldEdit {
text: String,
start: String,
/// The selected photos when the edit started (`photo.setMeta` `ids`).
ids: Vec<u64>,
/// The field said Mixed when the edit started.
mixed: bool,
/// Something was typed or deleted since.
typed: bool,
}
impl FieldEdit {
fn new(value: &str, mixed: bool, selected: &[PhotoId]) -> Self {
let ids = selected.iter().map(|id| id.0).collect();
FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false }
}
/// A Mixed field that was typed in and left empty: it clears the field on every selected photo.
fn clears(&self) -> bool {
self.mixed && self.typed
}
}
/// The Info panel's text fields, by their `photo.setMeta` key.
const META_TEXT_KEYS: [&str; 12] = [
"title",
"caption",
"altText",
"extendedDescription",
"copyright",
"usageTerms",
"copyrightUrl",
"creator",
"location",
"city",
"state",
"country",
];
/// The value of one of [`META_TEXT_KEYS`].
fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> {
Some(match key {
"title" => &m.title,
"caption" => &m.caption,
"altText" => &m.alt_text,
"extendedDescription" => &m.extended_description,
"copyright" => &m.copyright,
"usageTerms" => &m.usage_terms,
"copyrightUrl" => &m.copyright_url,
"creator" => &m.creator,
"location" => &m.location,
"city" => &m.city,
"state" => &m.state,
"country" => &m.country,
_ => return None,
})
}
/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected`
/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first
/// selected photo's.
pub(crate) fn shown_meta(
catalog: &lightcraft_catalog::Catalog,
p: &lightcraft_catalog::Photo,
selected: &[PhotoId],
for_selected: bool,
) -> lightcraft_catalog::Meta {
if !for_selected || selected.contains(&p.id) {
return p.meta.clone();
}
selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone())
}
/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of
/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws.
pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> {
let mut mixed = Vec::new();
for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) {
for key in META_TEXT_KEYS {
if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) {
mixed.push(key);
}
}
if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status {
mixed.push("copyrightStatus");
}
if !mixed.contains(&"gps") && m.gps != shown.gps {
mixed.push("gps");
}
if mixed.len() == META_TEXT_KEYS.len() + 2 {
break;
}
}
mixed
}
/// Target Photo / Selected Photos: whose metadata the fields below show while several photos
/// are selected (`view.infoSelectedPhotos`).
fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim));
ui.horizontal_wrapped(|ui| {
let on = app.ui.settings.info_selected_photos;
for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] {
if text_button(ui, id, label, on == selected).clicked() && on != selected {
let _ = app.run("view.infoSelectedPhotos", json!({"on": selected}));
}
}
});
ui.add_space(6.0);
}
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the
/// selected photos' statuses differ, and any status picked is given to all of them.
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim));
let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| {
let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() });
let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| {
for st in lightcraft_catalog::CopyrightStatus::ALL {
if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current {
let on = !mixed && st == current;
if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on {
let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()}));
}
}
@@ -913,27 +1049,34 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
ui.add_space(6.0);
}
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) {
/// A labeled metadata text field: the typed text lives in egui memory while focused and is
/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values
/// differ, so the field starts empty and says so, and is saved only when it was typed in (left
/// empty, it clears the field on every selected photo).
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
let id = egui::Id::new(("info-field", key));
let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string());
let value = if mixed { "" } else { value };
let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, &app.session.selected()));
let widget = format!("field:{key}");
let field = if lines > 1 {
crate::text_field::TextField::multiline(&widget, &mut text).rows(lines)
crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines)
} else {
crate::text_field::TextField::singleline(&widget, &mut text)
crate::text_field::TextField::singleline(&widget, &mut edit.text)
};
let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field };
let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui);
edit.typed |= edit.text != edit.start;
// the typed text outlives frames while it is edited (the photo's value is drawn otherwise)
if r.editing {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
ui.data_mut(|d| d.insert_temp(id, edit.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(id));
ui.data_mut(|d| d.remove::<FieldEdit>(id));
}
// Return (one line) or leaving saves; Esc gives the edit up
if r.committed() && text != value {
let _ = app.run("photo.setMeta", json!({key: text}));
if r.committed() && (edit.text != edit.start || edit.clears()) {
let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text}));
}
ui.add_space(6.0);
}
@@ -1014,11 +1157,16 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// switched away, doesn't claim the cursor whenever it is next drawn)
let bid = egui::Id::new("kw-begin");
let begin = ui.data_mut(|d| d.remove_temp::<u64>(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass));
// Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight
let asked = std::mem::take(&mut app.ui.focus_keywords);
let 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)
+17 -2
View File
@@ -870,8 +870,9 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
/// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and
/// which, and the three lines each of Info 1 and Info 2 shows (`view.viewOptions`). Changes apply
/// as they are made.
/// which, the three lines each of Info 1 and Info 2 shows, which of them shows briefly when the
/// photo changes, and whether the loupe shows its loading and rendering message
/// (`view.viewOptions`). Changes apply as they are made.
pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F};
heading(ui, t, crate::i18n::tr("Loupe View"));
@@ -906,7 +907,15 @@ pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Token
if r.clicked() {
*lines = defaults;
}
// one Info at most shows briefly: ticking one unticks the other
let info = if n == 1 { I::Basic } else { I::Exposure };
let mut briefly = app.ui.settings.loupe_info.briefly == info;
if check(ui, &format!("viewOptions.briefly{n}"), &mut briefly, "Show briefly when photo changes") {
app.ui.settings.loupe_info.briefly = if briefly { info } else { I::Off };
}
}
heading(ui, t, crate::i18n::tr("General"));
check(ui, "viewOptions.loupeMessages", &mut app.ui.settings.loupe_messages, "Show message when loading or rendering photos");
}
// ----------------------------------------------------------------------------------- Slideshow
@@ -972,6 +981,11 @@ fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN};
heading(ui, t, crate::i18n::tr("Playback"));
let s = app.session.slideshow.clone();
let mut v = s.manual;
if check(ui, "settings.slideshowManual", &mut v, "Manual slideshow") {
let _ = app.run("slideshow.playback", json!({"manual": v}));
}
hint(ui, t, crate::i18n::tr("A manual slideshow shows each slide until → or ← changes it. Exports still use the slide duration."));
row(ui, t, crate::i18n::tr("Slide duration"), |ui| {
duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX)
});
@@ -1343,6 +1357,7 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String>
// 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;
}
+5 -3
View File
@@ -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 {
+17
View File
@@ -212,6 +212,14 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi,
if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() {
run(app, &ctx, "print.template.save", json!({"name": pu.template_name}));
}
if app.services.pick_print_templates.is_some() && text_button(ui, "printTemplateImport", "Import…", false).clicked() {
let paths = app.services.pick_print_templates.as_mut().map(|pick| pick()).unwrap_or_default();
for path in paths {
if let Some(r) = run(app, &ctx, "print.template.import", json!({"path": path})) {
app.toast(&ctx, crate::i18n::tr_format!("Imported {name}", name = r["name"].as_str().unwrap_or_default()));
}
}
}
});
let names: Vec<String> = app.session.print_templates.iter().map(|t| t.name.clone()).collect();
for (i, name) in names.iter().enumerate() {
@@ -221,6 +229,15 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi,
if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() {
run(app, &ctx, "print.template.delete", json!({"name": name}));
}
if app.services.save_print_template.is_some() && text_button(ui, &format!("printTemplateExport-{i}"), "Export…", false).clicked() {
let stem = lightcraft_engine::slide_template::file_stem(name).unwrap_or_else(|| "Print Template".into());
let file = format!("{stem}.{}", lightcraft_engine::print::template::FILE_EXT);
if let Some(path) = app.services.save_print_template.as_mut().and_then(|save| save(&file))
&& let Some(r) = run(app, &ctx, "print.template.export", json!({"name": name, "path": path}))
{
app.toast(&ctx, crate::i18n::tr_format!("Exported to {path}", path = r["path"].as_str().unwrap_or_default()));
}
}
if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() {
run(app, &ctx, "print.template.apply", json!({"name": name}));
pu.page = 0;
+63 -4
View File
@@ -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<Item = (&str, (Modifiers, Key))> {
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();
+58 -2
View File
@@ -259,12 +259,31 @@ impl LoupeInfoField {
}
/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and
/// Info 2 ([`InfoOverlay::Exposure`]).
/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LoupeInfo {
pub info1: [LoupeInfoField; 3],
pub info2: [LoupeInfoField; 3],
/// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for
/// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]:
/// neither).
pub briefly: InfoOverlay,
}
/// How long Show Briefly When Photo Changes shows the info overlay (seconds).
pub const BRIEF_INFO_SECS: f64 = 3.0;
/// When the loupe last changed photo, for Show Briefly When Photo Changes
/// ([`LoupeInfo::briefly`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BriefInfo {
/// The photo the loupe showed last.
pub photo: Option<lightcraft_catalog::PhotoId>,
/// When the loupe changed to it (seconds on egui's clock).
pub since: f64,
/// The brief info overlay shows this frame.
pub shown: bool,
}
impl LoupeInfo {
@@ -276,7 +295,7 @@ impl LoupeInfo {
impl Default for LoupeInfo {
fn default() -> Self {
LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2 }
LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off }
}
}
@@ -458,12 +477,19 @@ pub struct AppSettings {
pub photocraft_file: EditFile,
/// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`).
pub memory_mb: u32,
/// The Info panel's metadata fields with several photos selected: true (the default) shows the
/// values the selected photos share and marks each field where they differ (Selected Photos),
/// false shows the active photo's values (Target Photo) (`view.infoSelectedPhotos`).
pub info_selected_photos: bool,
/// Filmstrip: file names above the thumbnails.
pub film_names: bool,
/// Filmstrip: rating / flag / edited badges on the thumbnails.
pub film_badges: bool,
/// Grid: when to show the rating / flag / edited badges.
pub grid_badges: GridBadges,
/// View ▸ View Options ▸ Show message when loading or rendering photos: the loupe says
/// "Rendering…" while the photo it shows is not the finished render.
pub loupe_messages: bool,
/// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none.
pub keymap: crate::shortcuts::Keymap,
/// Auto (follow the system), Dark or Light.
@@ -526,9 +552,11 @@ impl Default for AppSettings {
photocraft_path: String::new(),
photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() },
memory_mb: 0,
info_selected_photos: true,
film_names: true,
film_badges: true,
grid_badges: GridBadges::Auto,
loupe_messages: true,
keymap: Default::default(),
appearance_mode: AppearanceMode::Dark,
dark_theme: DarkTheme::Charcoal,
@@ -900,6 +928,9 @@ pub struct UiState {
/// Focus the search field on the next frame (Edit → Find…).
#[serde(skip)]
pub focus_search: bool,
/// Put the cursor in the Keywords panel's Add keyword box when it is next drawn (Add Keywords).
#[serde(skip)]
pub focus_keywords: bool,
/// A mask being renamed in the Masks list: its id and the edited name.
#[serde(skip)]
pub renaming_mask: Option<(u32, String)>,
@@ -1022,6 +1053,9 @@ pub struct UiState {
pub slideshow: Option<Box<crate::panels::slideshow::Run>>,
/// Info overlay on the loupe.
pub info_overlay: InfoOverlay,
/// When the loupe last changed photo (View Options' Show Briefly When Photo Changes).
#[serde(skip)]
pub brief_info: BriefInfo,
/// The loupe's layout image overlay (View ▸ Loupe Overlay).
pub layout_image: LayoutImage,
/// Navigator mini map in the loupe while zoomed in.
@@ -1508,6 +1542,7 @@ impl Default for UiState {
known_cards: None,
search: String::new(),
focus_search: false,
focus_keywords: false,
renaming_mask: None,
describe: None,
detail_due: None,
@@ -1555,6 +1590,7 @@ impl Default for UiState {
keyword_list_selected: None,
dragging_keyword: None,
info_overlay: InfoOverlay::Off,
brief_info: BriefInfo::default(),
layout_image: LayoutImage::default(),
navigator: true,
settings: AppSettings::default(),
@@ -1702,16 +1738,36 @@ mod loupe_info_tests {
fn view_options_lines_are_saved_with_the_settings() {
let mut settings = AppSettings::default();
settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName];
settings.loupe_info.briefly = InfoOverlay::Exposure;
let saved = serde_json::to_value(&settings).unwrap();
assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"]));
assert_eq!(saved["loupeInfo"]["briefly"], "exposure");
let read: AppSettings = serde_json::from_value(saved).unwrap();
assert_eq!(read.loupe_info, settings.loupe_info);
let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap();
assert_eq!(old.loupe_info, LoupeInfo::default());
// lines saved before Show Briefly When Photo Changes existed are kept, with it off
let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap();
assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2);
assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off);
let newer: AppSettings =
serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap();
assert_eq!(newer.loupe_info, LoupeInfo::default());
assert!(!newer.film_names, "the rest of the settings are still read");
}
/// Show message when loading or rendering photos is on until it is turned off, and is saved
/// with the app's settings.
#[test]
fn the_loading_message_choice_is_saved_with_the_settings() {
assert!(AppSettings::default().loupe_messages);
let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap();
assert!(old.loupe_messages, "a settings file from before the choice existed keeps the message");
let settings = AppSettings { loupe_messages: false, ..AppSettings::default() };
let saved = serde_json::to_value(&settings).unwrap();
assert_eq!(saved["loupeMessages"], false);
let read: AppSettings = serde_json::from_value(saved).unwrap();
assert!(!read.loupe_messages);
}
}
+66
View File
@@ -103,6 +103,27 @@ fn a_key_given_to_a_command_overrides_a_fixed_key() {
assert_eq!(before, after, "the rating key must not fire too");
}
/// A declared key that yields to another command's saved key can be taken back with ↺.
#[test]
fn restore_takes_back_a_declared_key_another_command_holds() {
use crate::shortcuts::shortcut_of;
let mut h = demo();
h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into());
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None);
h.app.run("app.shortcuts", json!({})).unwrap();
h.step();
h.step();
click(&mut h, "check:shortcuts.showAll");
click(&mut h, "field:shortcutsSearch");
assert_eq!(h.request("ui.text", json!({"text": "flag as pick"}), T)["ok"], true);
h.step();
h.step();
click(&mut h, "button:shortcutReset-photo.pick");
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P"));
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), None);
assert!(h.app.ui.settings.keymap.is_empty(), "{:?}", h.app.ui.settings.keymap);
}
/// Open the editor, filtered to `search`, and start recording for `id`.
fn record(h: &mut Headless, search: &str, id: &str) {
if h.app.ui.dialog.is_none() {
@@ -176,6 +197,51 @@ fn shift_insert_pastes_plainly() {
assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings");
assert_eq!(h.app.session.undo.len(), undo + 1, "the settings were pasted");
}
/// ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V) copy and paste metadata, as in Lightroom Classic's Library,
/// both as key presses and as the clipboard events Windows and Linux turn them into; the edit
/// settings clipboard is left alone.
#[test]
fn cmd_alt_shift_c_and_v_copy_and_paste_metadata() {
let mut h = demo();
let ids: Vec<_> = h.app.session.visible().iter().copied().take(2).collect();
let (a, b) = (ids[0], ids[1]);
let title = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.title.clone();
h.app.run("library.select", json!({"ids": [a.0]})).unwrap();
h.app.run("photo.setMeta", json!({"title": "Dawn", "creator": "Me"})).unwrap();
h.app.run("library.select", json!({"ids": [b.0]})).unwrap();
h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap();
let chord = json!({"cmd": true, "alt": true, "shift": true});
let press = |h: &mut Headless, k: &str| {
let mut p = chord.clone();
p["key"] = json!(k);
let r = h.request("ui.key", p, T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
};
h.app.run("library.select", json!({"ids": [a.0]})).unwrap();
press(&mut h, "C");
assert_eq!(h.app.session.meta_clipboard.as_ref().map(|m| m["title"].clone()), Some(json!("Dawn")), "the key copied the metadata");
h.app.run("library.select", json!({"ids": [b.0]})).unwrap();
press(&mut h, "V");
assert_eq!(title(&h, b), "Dawn", "the key pasted the metadata");
assert_eq!(h.app.session.catalog.photo(b).unwrap().meta.creator, "Me");
h.app.session.meta_clipboard = None;
h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap();
let all = egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT;
h.app.run("library.select", json!({"ids": [a.0]})).unwrap();
h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Copy]);
h.settle(SETTLE);
assert!(h.app.session.meta_clipboard.is_some(), "the Copy event with ⌥⇧ held copied the metadata");
h.app.run("library.select", json!({"ids": [b.0]})).unwrap();
h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Paste("text".into())]);
h.settle(SETTLE);
assert_eq!(title(&h, b), "Dawn", "the Paste event with ⌥⇧ held pasted the metadata");
assert!(h.app.session.clipboard.is_none(), "no edit settings were copied");
assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings");
}
/// The opening release can carry both physical shortcut keys and the replacement text.
#[test]
fn opening_numeric_release_keeps_keys_in_the_editor() {
+17
View File
@@ -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");
}
+36
View File
@@ -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]
+16 -4
View File
@@ -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<PathBuf, (Vec<u8>, std::time::SystemTime)> {
/// Every file under `dir`: relative path → (contents, length, modification time).
fn snapshot(dir: &Path) -> std::collections::BTreeMap<PathBuf, (Option<Vec<u8>>, 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<PathBuf, (Vec<u8>, 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<Vec<u8>> {
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]
+44
View File
@@ -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::<String>::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::<crate::pick::PickRequest>::new()));
+40
View File
@@ -154,6 +154,46 @@ fn arrows_cut_and_repeat_off_ends_after_the_last_slide() {
h.settle(SETTLE);
}
#[test]
fn a_manual_slideshow_waits_for_the_arrow_keys() {
let mut h = grid();
let photos = h.app.session.visible_cloned();
// the library setting, as the Settings checkbox sets it
run(&mut h, "slideshow.playback", json!({"manual": true}));
let r = run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0}));
assert_eq!(r["settings"]["manual"], true, "{r}");
// once the first two slides are rendered, and far longer than the slide duration: the first
// photo stays up
assert!(h.step_until(SETTLE, |h| has_slide(h, photos[0]) && has_slide(h, photos[1])), "the slides are rendered");
for _ in 0..120 {
h.step();
}
assert_eq!(photo_up(&h), photos[0], "nothing advances on its own");
let r = h.request("ui.key", json!({"key": "right"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(photo_up(&h), photos[1]);
assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows");
let s = run(&mut h, "slideshow.togglePause", json!({}));
assert_eq!((s["manual"].clone(), s["remaining"].clone()), (json!(true), json!(null)), "{s}");
run(&mut h, "slideshow.end", json!({}));
assert!(h.app.ui.slideshow.is_none());
// off again: it advances by itself
run(&mut h, "slideshow.playback", json!({"manual": false}));
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0}));
let up = photo_up(&h);
assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.current_photo() != Some(up))));
run(&mut h, "slideshow.end", json!({}));
// the Settings checkbox turns it on
run(&mut h, "view.slideshowOptions", json!({}));
h.step();
reveal(&mut h, "check:settings.slideshowManual");
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowManual"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.session.slideshow.manual, "the checkbox goes through slideshow.playback");
h.settle(SETTLE);
}
#[test]
fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() {
let order = |seed: u64| {
@@ -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<crate::menubar::MenuNode> {
use crate::menubar::MenuNode;
let bar = crate::menubar::menu_bar(app);
+6
View File
@@ -135,6 +135,12 @@ x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when
file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls
`focus.pump {pace}` every frame to drive it.
With several photos selected, the Info panel's Show Metadata For is `view.infoSelectedPhotos {on?}` (no `on`, or
`on: null`, toggles; any other value that is not true or false is refused; replies `{on}`;
`settings.infoSelectedPhotos`): off (Target Photo) shows the active photo's values, on (Selected Photos, the default)
shows the values the selected photos share, leaves each text field and GPS where they differ empty with a Mixed hint,
and shows Mixed as the Copyright Status when the statuses differ.
The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one,
Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme:
"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family.
+5 -3
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -43,8 +43,8 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
| 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) |
| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) |
| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), ⌘⌥⇧C / ⌘⌥⇧V (Copy / Paste Metadata), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) |
| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target. The Info panel's Show Metadata For choice (Target Photo / Selected Photos) shows the values the selected photos share and marks each field where they differ as Mixed; the All Metadata dialog still shows one photo's values with no "mixed" indication | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 1–3 | [ui-parity](ui-parity.md) |
Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them,
**~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)).
@@ -84,6 +84,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged |
| 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) |
| 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged |
| 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours |
| 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed |
+15 -1
View File
@@ -17,7 +17,10 @@ Select photos in Photo Grid or Square Grid, then use:
| `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) |
| `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo |
| `\` (backslash) | Show / hide the Filter Bar |
| ⌘K / Ctrl+K | Add Keywords: show the Keywords panel with the cursor in its Add keyword box |
| ⌘L / Ctrl+L | Enable Filters: switch the filter off and on |
| ⌘⌥⇧C / Ctrl+Alt+Shift+C | Copy Metadata: copy the active photo's metadata |
| ⌘⌥⇧V / Ctrl+Alt+Shift+V | Paste Metadata: paste the copied metadata on the selected photos |
Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail.
@@ -83,7 +86,18 @@ Survey and Reference, and leave text fields alone, so the search box still types
commands (`view.thumbSizeUp`, `view.thumbSizeDown`, reachable from the control channel and MCP, which
return `{"size"}`); the size is saved with the other interface settings.
Public behaviour references, checked 2026-10-08 (thumbnail size: 2026-10-13):
## Keyword sets
`⌥1`–`⌥9` (`Alt+1`–`Alt+9` on Windows and Linux) add or remove the current keyword set's keywords on
the selected photos. `⌥0` makes the next keyword set current and `⌥⇧0` the previous one (`Alt+0` /
`Alt+Shift+0`), in the order of the Keywords panel's Keyword Set menu: Recent Keywords, then the saved
sets. After the last set comes Recent Keywords again, and before Recent Keywords comes the last set;
with no saved sets the keys leave Recent Keywords current. The shortcut list does not say what happens
past either end; wrapping around is LightCraft's choice. The same steps are the Next / Previous
Keyword Set commands (`keyword.nextSet`, `keyword.previousSet`), which return what `keyword.sets`
returns, and both keys can be reassigned in Help → Keyboard Shortcuts.
Public behavior references, checked 2026-10-08 (thumbnail size: 2026-10-13; keyword sets: 2026-10-24):
- [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html)
- [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html)
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
+9 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA: ⌘⌥⇧C / ⌘⌥⇧V Copy / Paste Metadata; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named; KEYC-METADATA: ⌥0 / ⌥⇧0 next / previous keyword set; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named; KEYC-METADATA: ⌘K Add Keywords; KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
@@ -66,12 +66,16 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — |
| Select None | ⌘⇧A (+ ⌘D) | ⌘D | — |
**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. ⌘L / Ctrl+L (Enable Filters) puts the filter aside and shows every photo, and the next press brings back the same filter, stars, flag and text together; setting or clearing a filter while it is off drops the one put aside (not documented by Adobe, our choice).
**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. ⌘L / Ctrl+L (Enable Filters) puts the filter aside and shows every photo, and the next press brings back the same filter, stars, flag and text together; setting or clearing a filter while it is off drops the one put aside (not documented by Adobe, our choice). ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux, KEYC-METADATA) copy the active photo's metadata and paste it on the selected photos.
**Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md).
**Add Keywords key (KEYC-METADATA):** `⌘K` (`Ctrl+K` on Windows and Linux) is Add Keywords (`panel.addKeywords`): it shows the Keywords panel, opens its Keywording section when that is folded shut, and puts the cursor in the Add keyword box; pressed again it leaves the panel open. With no photo selected the panel says "Select photos to give them keywords." and the key does not move the cursor. The shortcut list names the key only; what the key opens is LightCraft's choice. Covered by `crates/ui-egui/src/tests_keywording.rs`.
**Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… ; View → Visualize HDR has no default key (`Shift+H` cycles the Tool Overlay, issue #224; `Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests.
**Keyword sets (KEYC-METADATA):** `⌥0` / `⌥⇧0` (`Alt+0` / `Alt+Shift+0` on Windows and Linux) make the next / previous keyword set current (`keyword.nextSet`, `keyword.previousSet`), in the Keyword Set menu's order: Recent Keywords, then the saved sets. After the last set comes Recent Keywords, and before Recent Keywords comes the last set (not documented by Adobe, our choice). `⌥1`–`⌥9` then apply the new set's keywords. Both keys can be reassigned in Help → Keyboard Shortcuts.
**Still missing / broken:**
- Tab / ⇧Tab (hide the side panels / all panels): Tab moves the keyboard focus here (accessibility), so it is
not bound; F5 (hide the top panel) is not bound either.
@@ -148,6 +152,9 @@ establish OS keyboard delivery or cross-platform acceptance.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | patch | KEYC-METADATA: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata |
| 2026-10-24 | minor | KEYC-METADATA: ⌥0 / ⌥⇧0 (Alt+0 / Alt+Shift+0) go to the next / previous keyword set, Recent Keywords first, wrapping around at either end |
| 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (Add Keywords) shows the Keywords panel with the cursor in its Add keyword box |
| 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged |
| 2026-10-23 | patch | KEYC-RATING: ⌘L / Ctrl+L (Library ▸ Enable Filters) switches the filter off and on, restoring it as it was |
| 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left |