From 4e0f8ced0c81a5e0050a697fd158bb776a649dc7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:36:17 -0400 Subject: [PATCH 01/19] Tests: the hover-preview tests wait for the hover render, not just a quiet spell profile_browser_previews_on_hover_and_applies_on_click failed on FreeBSD CI (loupe showed "render", not "hover"): settle() returns after 12 idle frames, which can come before the hover render lands on a loaded VM. A step_until helper waits for the condition itself; the preset hover test had the same race. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a6b8fc43..4c7115de 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -204,6 +204,20 @@ impl Headless { } } + /// Run frames until `done` holds or `timeout` passes (a render that finishes on a busy machine + /// after a quiet spell, where [`Self::settle`] alone would stop too early). Returns `done`. + pub fn step_until(&mut self, timeout: Duration, done: impl Fn(&Self) -> bool) -> bool { + let t0 = Instant::now(); + while !done(self) { + if t0.elapsed() > timeout { + return false; + } + self.step(); + std::thread::sleep(Duration::from_millis(1)); + } + true + } + /// Rasterize the last frame (with the photo textures). pub fn paint(&self) -> ColorImage { self.view.paint(&HashMap::new()) @@ -373,7 +387,8 @@ mod tests { let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); - h.step(); + // the hover render can land after a quiet spell on a loaded machine (FreeBSD CI): wait for it + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); @@ -409,7 +424,7 @@ mod tests { let before = photo(&h); h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t); h.settle(SETTLE); - h.step(); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W")); assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); let after = photo(&h); From 517c745fad7b0f425fe59d51f1dcff0182404ce7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 10:18:53 -0400 Subject: [PATCH 02/19] Tests: the folder-tree test waits for the subfolder row, not three frames local_folder_tree_expands_and_browses failed on FreeBSD CI (707138f) and under local load: the expanded folder's listing can land after the fixed three frames. It now steps until the row is laid out (bounded by SETTLE). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tests_masking.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 2abc141f..9c8ea349 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -371,10 +371,10 @@ fn local_folder_tree_expands_and_browses() { let base_s = base.to_string_lossy().to_string(); let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T); assert_eq!(r["ok"], true, "{r}"); - for _ in 0..3 { - h.step(); - } let trip = base.join("Trip").to_string_lossy().to_string(); + // the folder listing can land a few frames later on a loaded machine (FreeBSD CI): wait for the row + let row = format!("source:local:{trip}"); + h.step_until(SETTLE, |h| h.app.widgets.iter().any(|(w, _)| *w == row)); let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{trip}")}), T); assert_eq!(r["ok"], true, "the subfolder is listed: {r}"); assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(trip.clone()), "clicking it browses it"); From 6408ccf2442c233a67035ab557a83dff7a46f0c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 10:58:29 -0400 Subject: [PATCH 03/19] Tests: the keyword-list test lets the list settle before clicking rows keyword_list_filters_renames_and_suggests failed on FreeBSD CI (b35487a) and locally (2 of 13 runs under load): ui.clickWidget clicks last frame's rect, and right after opening the panel / expanding "travel" the rows could still move, so the click hit "sunrise" instead of "travel|italy". Settling first: 30 of 30 runs pass. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 4c7115de..4b599d02 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -535,12 +535,16 @@ mod tests { ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); h.request("ui.set", json!({"leftPanel": true}), t); + // clicks land on last frame's layout: let the keyword list settle first (on a loaded machine a + // row could still move, and the click then hit its neighbour, e.g. "sunrise") + h.settle(SETTLE); let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); assert_eq!(h.app.session.visible_cloned().len(), 3); // open the level, filter by the child h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); + h.settle(SETTLE); let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.session.visible_cloned().len(), 2); From a72118ec6e65f25f0fa43c0c7ebce8303e148837 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 13:33:38 -0400 Subject: [PATCH 04/19] Library: Random sort with a stable seed and Reshuffle SortKey::Random orders photos by a splitmix64 rank of (Sort.seed, id): same seed gives the same order, and added/edited photos never reshuffle the others. library.sort takes an optional seed; library.shuffle picks a new one (below 2^53 for JSON clients). Menus gain Random and Reshuffle; Ascending/Descending are disabled for Random. No effect in Recently Added (always newest import first). Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/lib.rs | 2 +- crates/catalog/src/query.rs | 22 +++++++- crates/catalog/src/tests.rs | 66 ++++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 39 ++++++++++++++- crates/engine/src/tests_libops.rs | 69 ++++++++++++++++++++++++++ crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/menubar.rs | 7 ++- crates/ui-egui/src/panels/bottombar.rs | 19 ++++--- docs/parity.md | 4 +- 11 files changed, 220 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 097a7179..208a86d0 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -35,7 +35,7 @@ use lightcraft_develop::DevelopSettings; pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of}; pub use lock::{LibraryLock, LockError, LockOwner}; pub use model::*; -pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey}; +pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64}; pub use rules::{Match, Rule, RuleSet}; use serde::{Deserialize, Serialize}; pub use store::{FsStore, MemStore, Store}; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 384c2ae5..d06e1370 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -72,6 +72,9 @@ pub enum SortKey { FileName, Rating, FileSize, + /// A shuffle fixed by [`Sort::seed`]: the same seed always gives the same order, and photos + /// added or edited later never reshuffle the others. Pick a new seed to reshuffle. + Random, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -81,14 +84,30 @@ pub struct Sort { pub ascending: bool, /// Date headers in the grid (date sort keys only). pub group: crate::GroupBy, + /// Which shuffle [`SortKey::Random`] gives (ignored by the other keys). + pub seed: u64, } impl Default for Sort { fn default() -> Self { - Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto } + Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 } } } +/// splitmix64 finaliser: a stateless, platform-independent mix (no RNG state, no `rand` version +/// to drift), so a seed names the same shuffle on every machine. +pub fn mix64(x: u64) -> u64 { + let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// A photo's place in the shuffle for `seed`. +fn shuffle_rank(seed: u64, id: PhotoId) -> u64 { + mix64(mix64(seed) ^ id.0) +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); if let Some((field, val)) = t.split_once(':') { @@ -307,6 +326,7 @@ impl Catalog { SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), SortKey::Rating => a.rating.cmp(&b.rating), SortKey::FileSize => a.file_size.cmp(&b.file_size), + SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), } .then(a.id.cmp(&b.id)); if sort.ascending { o } else { o.reverse() } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 6f76b694..49f0ac4d 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -380,3 +380,69 @@ fn empty_regions_are_not_serialized_and_default_when_missing() { let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap(); assert_eq!(back, with); } + +// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos + +fn many(c: &mut Catalog, n: usize) -> Vec { + (0..n).map(|i| photo(c, &format!("p{i:03}.jpg"), &format!("2026-04-{:02}T10:00:00", i % 28 + 1))).collect() +} + +fn random(seed: u64) -> Sort { + Sort { key: SortKey::Random, seed, ..Default::default() } +} + +#[test] +fn random_sort_is_a_reproducible_permutation() { + let mut c = Catalog::new(); + let ids = many(&mut c, 50); + let a = c.query(&Filter::default(), &random(7)); + assert_eq!(a, c.query(&Filter::default(), &random(7)), "same seed, same order"); + let mut sorted = a.clone(); + sorted.sort(); + let mut all = ids.clone(); + all.sort(); + assert_eq!(sorted, all, "every photo exactly once"); + assert_ne!(a, c.query(&Filter::default(), &random(8)), "another seed, another order"); + assert_ne!(a, c.query(&Filter::default(), &Sort::default()), "not just the date order"); +} + +#[test] +fn random_sort_keeps_the_relative_order_when_the_set_changes() { + let mut c = Catalog::new(); + let ids = many(&mut c, 30); + let before = c.query(&Filter::default(), &random(3)); + let added = photo(&mut c, "new.jpg", "2026-05-01T10:00:00"); + c.apply(Op::SetRating { id: ids[0], rating: 5 }).unwrap(); + let after: Vec = c.query(&Filter::default(), &random(3)).into_iter().filter(|id| *id != added).collect(); + assert_eq!(after, before, "adding a photo or editing metadata must not reshuffle the others"); +} + +#[test] +fn random_sort_respects_the_filter_and_handles_tiny_sets() { + let mut c = Catalog::new(); + assert!(c.query(&Filter::default(), &random(1)).is_empty()); + let ids = many(&mut c, 10); + c.apply(Op::SetRating { id: ids[4], rating: 5 }).unwrap(); + assert_eq!(c.query(&Filter { rating: 5, ..Default::default() }, &random(1)), vec![ids[4]]); +} + +#[test] +fn random_sort_direction_reverses_and_serde_defaults_hold() { + let mut c = Catalog::new(); + many(&mut c, 20); + let desc = c.query(&Filter::default(), &Sort { ascending: false, ..random(5) }); + let mut asc = c.query(&Filter::default(), &Sort { ascending: true, ..random(5) }); + asc.reverse(); + assert_eq!(desc, asc); + // saved sorts from before the seed existed still load + let old: Sort = serde_json::from_str(r#"{"key":"fileName","ascending":true}"#).unwrap(); + assert_eq!(old.seed, 0); + assert_eq!(serde_json::from_str::(r#"{"key":"random","seed":9}"#).unwrap(), Sort { key: SortKey::Random, seed: 9, ..Default::default() }); +} + +#[test] +fn random_sort_has_no_date_headers() { + let mut c = Catalog::new(); + let ids = many(&mut c, 5); + assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty()); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 84100638..da4debf2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -224,6 +224,22 @@ fn select_by(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": n})) } +/// `seed` as a non-negative integer, or `default` when absent; anything else is an error. +fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { + match p.get("seed") { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_u64().ok_or_else(|| bad(cmd, "seed must be a non-negative integer")), + } +} + +/// The seed after `prev` at time `now`: a mix of `prev` and the clock text, so repeated reshuffles +/// within one clock tick still differ (barring a 2^-53 collision). Kept below 2^53 so JSON +/// clients that read numbers as doubles (web, MCP agents) get the same seed back. +fn next_seed(prev: u64, now: &str) -> u64 { + let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); + (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & ((1 << 53) - 1) +} + pub fn specs() -> Vec { vec![ // ---- view source / filter / sort @@ -281,7 +297,7 @@ pub fn specs() -> Vec { "Sort", ["View", "Sort"], None, - "{key?: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool, group?: auto|none|day|month|year}", + "{key?: captureDate|importDate|editDate|fileName|rating|fileSize|random, ascending?: bool, group?: auto|none|day|month|year, seed?: u64 (the shuffle `random` gives)}", always, |s, p| { let key: SortKey = match p.get("key") { @@ -292,7 +308,26 @@ pub fn specs() -> Vec { Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, None => s.sort.group, }; - s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group }; + let seed = seed_param(p, "library.sort", s.sort.seed)?; + s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed }; + ok() + } + ), + cmd!( + "library.shuffle", + "Reshuffle", + ["View", "Sort"], + None, + "{seed?: u64} — sort at random; without `seed` a new shuffle each time", + always, + |s, p| { + let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { + given if p.get("seed").is_some() => given, + // derived from the previous seed and the session clock: reproducible under a test + // clock, and no RNG needed + prev => next_seed(prev, &(s.clock)()), + }; + s.sort = Sort { key: SortKey::Random, seed, ..s.sort }; ok() } ), diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 90941498..9bc6f055 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -466,3 +466,72 @@ fn tracklog_auto_tag() { assert!(s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [ids[0]], "offset": "noon"})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +// ---- random sort: Given the library, When the user picks Random, Then the grid is a stable shuffle + +#[test] +fn random_sort_is_stable_until_reshuffled() { + use lightcraft_catalog::SortKey; + let mut s = Session::with_demo(); + let dated = s.visible_cloned(); + s.execute("library.sort", &json!({"key": "random", "seed": 11})).unwrap(); + assert_eq!((s.sort.key, s.sort.seed), (SortKey::Random, 11)); + let a = s.visible_cloned(); + assert_ne!(a, dated); + let mut sorted = a.clone(); + sorted.sort(); + let mut all = dated.clone(); + all.sort(); + assert_eq!(sorted, all, "a permutation of the same photos"); + // an unrelated catalog change must not move anything + let first = a[0]; + s.execute("photo.rate", &json!({"rating": 4, "ids": [first.0]})).unwrap(); + assert_eq!(s.visible_cloned(), a, "same seed, same grid after an edit"); + assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers for a shuffle"); + // Reshuffle picks a new seed (and works even with a frozen clock) + s.execute("library.shuffle", &json!({})).unwrap(); + let seed1 = s.sort.seed; + assert_eq!(s.sort.key, SortKey::Random); + assert_ne!(seed1, 11); + assert_ne!(s.visible_cloned(), a); + s.execute("library.shuffle", &json!({})).unwrap(); + assert_ne!(s.sort.seed, seed1, "every reshuffle is new"); + assert!(s.sort.seed < 1 << 53, "seeds survive a round trip through a JSON double"); + // an explicit seed is reproducible + s.execute("library.shuffle", &json!({"seed": 11})).unwrap(); + assert_eq!(s.visible_cloned(), a); +} + +#[test] +fn random_sort_rejects_bad_params() { + let mut s = Session::with_demo(); + let before = s.sort; + assert!(s.execute("library.sort", &json!({"seed": "x"})).is_err()); + assert!(s.execute("library.sort", &json!({"seed": -1})).is_err()); + assert!(s.execute("library.shuffle", &json!({"seed": 1.5})).is_err()); + assert_eq!(s.sort, before, "a rejected command leaves the sort alone"); +} + +#[test] +fn random_sort_applies_to_albums_and_folders_too() { + let mut s = Session::with_demo(); + s.execute("library.sort", &json!({"key": "random", "seed": 5})).unwrap(); + let all = s.visible_cloned(); + let ids: Vec = all.iter().take(12).map(|i| i.0).collect(); + let album = s.execute("album.create", &json!({"name": "Shuffled"})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + let in_album = s.visible_cloned(); + assert_eq!(in_album.len(), 12); + let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect(); + assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order"); +} + +/// The shuffle comes back after a restart (the sort is part of the saved view state). +#[test] +fn random_sort_survives_view_state_round_trip() { + use lightcraft_catalog::{Sort, SortKey}; + let sort = Sort { key: SortKey::Random, seed: u64::MAX, ..Default::default() }; + let back: Sort = serde_json::from_str(&serde_json::to_string(&sort).unwrap()).unwrap(); + assert_eq!(back, sort); +} diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index f131d0b4..ca2089af 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -287,6 +287,8 @@ "File Name": "ファイル名", "File Path": "ファイルの場所", "File Size": "ファイルサイズ", + "Random": "ランダム", + "Reshuffle": "シャッフルし直す", "File name": "ファイル名", "Fill Edges": "境界を補完", "Filmstrip": "フィルムストリップ", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7dc4d7c3..9bf5a597 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -287,6 +287,8 @@ "File Name": "文件名", "File Path": "文件路径", "File Size": "文件大小", + "Random": "随机", + "Reshuffle": "重新随机排序", "File name": "文件名", "Fill Edges": "填充边缘", "Filmstrip": "胶片窗格", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 79c95291..db538f48 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -389,6 +389,8 @@ "File Name": "檔案名稱", "File Path": "檔案路徑", "File Size": "檔案大小", + "Random": "隨機", + "Reshuffle": "重新隨機排序", "File name": "檔案名稱", "Fill Edges": "填補邊緣", "Film": "底片", diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7e8054f2..f47d86de 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -458,13 +458,16 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { ("File Name", FileName, "fileName"), ("Rating", Rating, "rating"), ("File Size", FileSize, "fileSize"), + ("Random", Random, "random"), ] .into_iter() .map(|(label, key, k)| item("library.sort", json!({"key": k}), label, None, true, Some(cur.key == key))) .collect(); + v.push(item("library.shuffle", json!({}), "Reshuffle", None, cur.key == Random, None)); v.push(MenuNode::Separator); - v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, true, Some(cur.ascending))); - v.push(item("library.sort", json!({"ascending": false}), "Descending", None, true, Some(!cur.ascending))); + // a shuffle has no direction worth choosing + v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, cur.key != Random, Some(cur.ascending))); + v.push(item("library.sort", json!({"ascending": false}), "Descending", None, cur.key != Random, Some(!cur.ascending))); v.push(MenuNode::Separator); use lightcraft_catalog::GroupBy; let groups = [ diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index e36cba6a..7ae6f3fe 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -242,18 +242,25 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("File Name", FileName, "fileName"), ("Rating", Rating, "rating"), ("File Size", FileSize, "fileSize"), + ("Random", Random, "random"), ] { if ui.selectable_label(cur.key == key, crate::i18n::tr(label)).clicked() { let _ = app.run("library.sort", json!({"key": k})); } } + if cur.key == Random && ui.button(crate::i18n::tr("Reshuffle")).clicked() { + let _ = app.run("library.shuffle", json!({})); + } ui.separator(); - if ui.selectable_label(cur.ascending, crate::i18n::tr("Ascending")).clicked() { - let _ = app.run("library.sort", json!({"ascending": true})); - } - if ui.selectable_label(!cur.ascending, crate::i18n::tr("Descending")).clicked() { - let _ = app.run("library.sort", json!({"ascending": false})); - } + // a shuffle has no direction worth choosing + ui.add_enabled_ui(cur.key != Random, |ui| { + if ui.selectable_label(cur.ascending, crate::i18n::tr("Ascending")).clicked() { + let _ = app.run("library.sort", json!({"ascending": true})); + } + if ui.selectable_label(!cur.ascending, crate::i18n::tr("Descending")).clicked() { + let _ = app.run("library.sort", json!({"ascending": false})); + } + }); ui.separator(); ui.label(egui::RichText::new(crate::i18n::tr("Group by date")).weak()); for (label, g, k) in [ diff --git a/docs/parity.md b/docs/parity.md index 82729971..e1198880 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -199,7 +199,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | | LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | -| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | +| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle`, `crates/ui-egui/src/panels/bottombar.rs` | Random sort (seeded, stable under edits; Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library | ## E. Metadata (META) @@ -570,7 +570,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | | | MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor` | colour cycles through the panel's swatches (no params) or takes `color` / `opacity` | | MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | ✅ | `cmd:library.browse` (`subfolders`) | toggle in the folder header; imports of folders are recursive | -| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key | +| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle` | Random + Reshuffle; no colour-label key | | MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | | MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | `cmd:view.photoCounts` | View ▸ Show Photo Counts toggles the left panel's counts | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | From 17daca8a87acdbb6b454808151af66320138b2e1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 13:42:42 -0400 Subject: [PATCH 05/19] Menus: list Reshuffle once in the Sort submenu library.shuffle's registry menu path made the menu builder append it a second time after the hand-built entry. Hide it from the auto-listing; test that it appears once and is enabled only while sorting at random. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/menubar.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f47d86de..bcd1ae4a 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -251,6 +251,7 @@ const HIDDEN: &[&str] = &[ "photo.unflag", "photo.label", "library.sort", + "library.shuffle", "album.addPhotos", "album.create", "library.import", @@ -790,6 +791,30 @@ mod tests { }) } + /// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), + /// only while sorting at random, and the direction items are off for a shuffle. + #[test] + fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { + fn sort_children(bar: &[(String, Vec)]) -> Vec { + let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; + view.iter() + .find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), + _ => None, + }) + .expect("Sort submenu") + } + let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); + let mut a = app(); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); + a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); + } + /// File opens with the import entry points, worded as importing (not as adding a sidebar /// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. #[test] From b310de1bd65a269195e8bc3009781cfb541a1f49 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 13:46:24 -0400 Subject: [PATCH 06/19] Menus: no direction check while sorting at random Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/menubar.rs | 30 ++++++++++++++++++++++++-- crates/ui-egui/src/panels/bottombar.rs | 4 ++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index bcd1ae4a..f5c23bb4 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -467,8 +467,22 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item("library.shuffle", json!({}), "Reshuffle", None, cur.key == Random, None)); v.push(MenuNode::Separator); // a shuffle has no direction worth choosing - v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, cur.key != Random, Some(cur.ascending))); - v.push(item("library.sort", json!({"ascending": false}), "Descending", None, cur.key != Random, Some(!cur.ascending))); + v.push(item( + "library.sort", + json!({"ascending": true}), + "Ascending", + None, + cur.key != Random, + (cur.key != Random).then_some(cur.ascending), + )); + v.push(item( + "library.sort", + json!({"ascending": false}), + "Descending", + None, + cur.key != Random, + (cur.key != Random).then_some(!cur.ascending), + )); v.push(MenuNode::Separator); use lightcraft_catalog::GroupBy; let groups = [ @@ -813,6 +827,18 @@ mod tests { let kids = sort_children(&menu_bar(&a)); assert_eq!(count(&kids), 1); assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); + // no direction is shown as chosen while shuffling + let dir_checked = |kids: &[MenuNode]| { + kids.iter() + .filter_map(|n| match n { + MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), + _ => None, + }) + .collect::>() + }; + assert_eq!(dir_checked(&kids), vec![None, None]); + a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); + assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); } /// File opens with the import entry points, worded as importing (not as adding a sidebar diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 7ae6f3fe..f75feca4 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -254,10 +254,10 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.separator(); // a shuffle has no direction worth choosing ui.add_enabled_ui(cur.key != Random, |ui| { - if ui.selectable_label(cur.ascending, crate::i18n::tr("Ascending")).clicked() { + if ui.selectable_label(cur.key != Random && cur.ascending, crate::i18n::tr("Ascending")).clicked() { let _ = app.run("library.sort", json!({"ascending": true})); } - if ui.selectable_label(!cur.ascending, crate::i18n::tr("Descending")).clicked() { + if ui.selectable_label(cur.key != Random && !cur.ascending, crate::i18n::tr("Descending")).clicked() { let _ = app.run("library.sort", json!({"ascending": false})); } }); From 89446df6a88fc319b7219d668faa2e07d5f51f33 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 14:03:52 -0400 Subject: [PATCH 07/19] Random sort: review fixes (null seed, 2^53 seed bound, real tests), README - library.shuffle treats a null seed as none given - seeds are limited to 0..2^53-1 so JSON-double clients read them back exactly - album test now fails if manual order overrides Random; persistence test goes through close/reopen of the library instead of a serde round trip - README lists random among the sort options Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- crates/engine/src/cmd/library.rs | 13 ++++++++----- crates/engine/src/tests_libops.rs | 21 ++++++++------------- crates/engine/src/tests_organize.rs | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9692b636..29c28a5f 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick **Folders**, and **Recently Deleted**. Rate with 0–5, flag with P / X / U, colour-label with 6–9. Search understands fields: `rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date, -import date, edit date, name, rating or size. The justified **Photo Grid** and **Square Grid** views are virtualized, +import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index da4debf2..6f933252 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -224,11 +224,14 @@ fn select_by(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": n})) } -/// `seed` as a non-negative integer, or `default` when absent; anything else is an error. +/// Seeds stay exactly representable as a JSON double, so web and agent clients read back what they wrote. +const MAX_SEED: u64 = 1 << 53; + +/// `seed` as an integer in `0..2^53`, or `default` when absent or null; anything else is an error. fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { match p.get("seed") { None | Some(Value::Null) => Ok(default), - Some(v) => v.as_u64().ok_or_else(|| bad(cmd, "seed must be a non-negative integer")), + Some(v) => v.as_u64().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), } } @@ -237,7 +240,7 @@ fn seed_param(p: &Value, cmd: &str, default: u64) -> Result { /// clients that read numbers as doubles (web, MCP agents) get the same seed back. fn next_seed(prev: u64, now: &str) -> u64 { let t = now.bytes().fold(0u64, |h, b| lightcraft_catalog::mix64(h ^ u64::from(b))); - (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & ((1 << 53) - 1) + (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) } pub fn specs() -> Vec { @@ -318,11 +321,11 @@ pub fn specs() -> Vec { "Reshuffle", ["View", "Sort"], None, - "{seed?: u64} — sort at random; without `seed` a new shuffle each time", + "{seed?: 0..2^53-1} — sort at random; without `seed` a new shuffle each time", always, |s, p| { let seed = match seed_param(p, "library.shuffle", s.sort.seed)? { - given if p.get("seed").is_some() => given, + given if p.get("seed").is_some_and(|v| !v.is_null()) => given, // derived from the previous seed and the session clock: reproducible under a test // clock, and no RNG needed prev => next_seed(prev, &(s.clock)()), diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 9bc6f055..f9837c50 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -494,8 +494,8 @@ fn random_sort_is_stable_until_reshuffled() { assert_eq!(s.sort.key, SortKey::Random); assert_ne!(seed1, 11); assert_ne!(s.visible_cloned(), a); - s.execute("library.shuffle", &json!({})).unwrap(); - assert_ne!(s.sort.seed, seed1, "every reshuffle is new"); + s.execute("library.shuffle", &json!({"seed": null})).unwrap(); + assert_ne!(s.sort.seed, seed1, "every reshuffle is new, and a null seed means none given"); assert!(s.sort.seed < 1 << 53, "seeds survive a round trip through a JSON double"); // an explicit seed is reproducible s.execute("library.shuffle", &json!({"seed": 11})).unwrap(); @@ -509,14 +509,18 @@ fn random_sort_rejects_bad_params() { assert!(s.execute("library.sort", &json!({"seed": "x"})).is_err()); assert!(s.execute("library.sort", &json!({"seed": -1})).is_err()); assert!(s.execute("library.shuffle", &json!({"seed": 1.5})).is_err()); - assert_eq!(s.sort, before, "a rejected command leaves the sort alone"); + assert!(s.execute("library.sort", &json!({"seed": 1u64 << 53})).is_err(), "beyond what a JSON double holds exactly"); + assert!(s.execute("library.sort", &json!({"seed": (1u64 << 53) - 1})).is_ok()); + assert_eq!(s.sort.key, before.key, "a rejected command leaves the sort alone"); } #[test] -fn random_sort_applies_to_albums_and_folders_too() { +fn random_sort_applies_to_albums_too() { let mut s = Session::with_demo(); s.execute("library.sort", &json!({"key": "random", "seed": 5})).unwrap(); let all = s.visible_cloned(); + // manual order shows newest-first (descending), i.e. the reverse of this insertion order, so it + // differs from the shuffle unless Random really overrides it let ids: Vec = all.iter().take(12).map(|i| i.0).collect(); let album = s.execute("album.create", &json!({"name": "Shuffled"})).unwrap()["id"].as_u64().unwrap(); s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap(); @@ -526,12 +530,3 @@ fn random_sort_applies_to_albums_and_folders_too() { let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect(); assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order"); } - -/// The shuffle comes back after a restart (the sort is part of the saved view state). -#[test] -fn random_sort_survives_view_state_round_trip() { - use lightcraft_catalog::{Sort, SortKey}; - let sort = Sort { key: SortKey::Random, seed: u64::MAX, ..Default::default() }; - let back: Sort = serde_json::from_str(&serde_json::to_string(&sort).unwrap()).unwrap(); - assert_eq!(back, sort); -} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index f23ae9bd..7fb24806 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -242,3 +242,17 @@ fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { } assert_eq!(s.filter, Default::default()); } + +/// A shuffle is part of the saved view: the same seed (and so the same grid) is back on the next launch. +#[test] +fn random_sort_persists_with_the_view() { + let dir = temp_dir("random-view"); + let mut s = open(&dir); + s.execute("library.sort", &json!({"key": "random", "seed": 123_456_789})).unwrap(); + s.close_library().unwrap(); + drop(s); + let s2 = open(&dir); + assert_eq!(s2.sort.key, lightcraft_catalog::SortKey::Random); + assert_eq!(s2.sort.seed, 123_456_789); + let _ = std::fs::remove_dir_all(&dir); +} From 6de619ca9b7f8335723f958c8316aa2420773b11 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 14:08:11 -0400 Subject: [PATCH 08/19] Random sort: choosing Random starts a fresh shuffle Switching to Random from another key (without an explicit seed) derives a new seed instead of reusing the one left behind, which was the fixed seed-0 order on a new library. Re-choosing Random or changing only the direction keeps the shuffle on screen. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 7 ++++++- crates/engine/src/tests_libops.rs | 21 +++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 6f933252..a2ca64eb 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -311,7 +311,12 @@ pub fn specs() -> Vec { Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, None => s.sort.group, }; - let seed = seed_param(p, "library.sort", s.sort.seed)?; + let mut seed = seed_param(p, "library.sort", s.sort.seed)?; + let explicit = p.get("seed").is_some_and(|v| !v.is_null()); + if key == SortKey::Random && s.sort.key != SortKey::Random && !explicit { + // switching to Random is a fresh shuffle, not whatever seed was left behind + seed = next_seed(seed, &(s.clock)()); + } s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group, seed }; ok() } diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index f9837c50..ac0e5c57 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -530,3 +530,24 @@ fn random_sort_applies_to_albums_too() { let expect: Vec<_> = all.iter().filter(|i| in_album.contains(i)).copied().collect(); assert_eq!(in_album, expect, "the album shows its photos in the shuffle's order, not manual order"); } + +/// Choosing Random from another sort is a fresh shuffle (not the fixed seed-0 order); picking it +/// again, or changing only the direction, keeps the shuffle on screen; an explicit seed wins. +#[test] +fn choosing_random_starts_a_fresh_shuffle() { + let mut s = Session::with_demo(); + assert_eq!(s.sort.seed, 0); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + let first = s.sort.seed; + assert_ne!(first, 0, "the first Random is not the fixed seed-0 order"); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + s.execute("library.sort", &json!({"ascending": true})).unwrap(); + assert_eq!(s.sort.seed, first, "already random: the shuffle stays"); + s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + assert_ne!(s.sort.seed, first, "coming back to Random reshuffles"); + s.execute("library.sort", &json!({"key": "rating"})).unwrap(); + s.execute("library.sort", &json!({"key": "random", "seed": 42})).unwrap(); + assert_eq!(s.sort.seed, 42, "an explicit seed is honoured"); + assert!(s.sort.seed < 1 << 53); +} diff --git a/docs/parity.md b/docs/parity.md index e1198880..db5d72fb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -199,7 +199,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | | LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | -| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle`, `crates/ui-egui/src/panels/bottombar.rs` | Random sort (seeded, stable under edits; Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order | +| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle`, `crates/ui-egui/src/panels/bottombar.rs` | Random sort (seeded, stable under edits; choosing Random or Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library | ## E. Metadata (META) From 036993f194787464f5e0dea7a7d176fd8268addd Mon Sep 17 00:00:00 2001 From: MusseJusse Date: Wed, 7 Oct 2026 19:38:23 +0100 Subject: [PATCH 09/19] M16.1: complete navigation arrowheads Made with gpt-6.1-sol using the Codex harness in T3 Code. --- crates/ui-egui/src/icons.rs | 35 +++++++++++++++++++++++++++++++++-- docs/parity.md | 2 +- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b4afd8c1..acbbba02 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -151,8 +151,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(3.0, 4.0, 17.0, 16.0, 1.5); pen.line(&[(8.0, 4.0), (8.0, 16.0)]); } - Back => pen.line(&[(16.0, 10.0), (4.0, 10.0), (9.0, 5.0)]), - Forward => pen.line(&[(4.0, 10.0), (16.0, 10.0), (11.0, 5.0)]), + Back => { + pen.line(&[(16.0, 10.0), (4.0, 10.0)]); + pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); + } + Forward => { + pen.line(&[(4.0, 10.0), (16.0, 10.0)]); + pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); + } Search => { pen.circle(8.5, 8.5, 5.0); pen.line(&[(12.2, 12.2), (16.5, 16.5)]); @@ -479,3 +485,28 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_arrowheads_have_both_halves() { + for icon in [Icon::Back, Icon::Forward] { + for scale in [1.0, 2.0] { + let mut view = crate::headless::HeadlessView::new(); + let raw = crate::headless::HeadlessView::raw_input(vec2(64.0, 64.0), scale, 0.0, vec![]); + view.run(raw, |ui| { + ui.painter().rect_filled(ui.max_rect(), 0.0, Color32::BLACK); + paint(ui.painter(), Rect::from_center_size(pos2(32.0, 32.0), vec2(18.6, 18.6)), icon, Color32::WHITE); + }); + let image = view.paint(&Default::default()); + let middle = image.pixels.len() / 2; + let upper: u64 = image.pixels[..middle].iter().map(|p| u64::from(p.r())).sum(); + let lower: u64 = image.pixels[middle..].iter().map(|p| u64::from(p.r())).sum(); + assert!(upper > 0 && lower > 0, "the arrow must render"); + assert!(upper.abs_diff(lower) < upper / 20, "arrowhead must be symmetric at scale {scale}: {upper} above, {lower} below"); + } + } + } +} diff --git a/docs/parity.md b/docs/parity.md index 82729971..741fd77d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -696,7 +696,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | -| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | | +| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`) | top-bar navigation arrowheads render both halves at 1× and 2× scale | | KEY-COMMIT | Commit tool — Return | P1 | ✅ | `cmd:tool.done` | Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) | | KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching | | KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel | From b040f9bb8fc561171604fd809bb440e77f0dc1f7 Mon Sep 17 00:00:00 2001 From: NDsans Date: Thu, 8 Oct 2026 03:08:28 +0800 Subject: [PATCH 10/19] Build: resolve a relative CRAFT_FONTS_DIR against the workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build script's cwd is its package root, so CRAFT_FONTS_DIR=../craft-fonts — the spelling the README and craft-fonts' integration docs both use, written relative to the workspace root — resolved to crates/craft-fonts and failed. The failure is silent from the user's side: the app runs, the language menu lists every language, and selecting Chinese or Japanese paints boxes. Warn when no font ends up embedded, not only when the variable is set but wrong. --- crates/engine/build.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/engine/build.rs b/crates/engine/build.rs index 6665cf44..7350c6d8 100644 --- a/crates/engine/build.rs +++ b/crates/engine/build.rs @@ -7,16 +7,20 @@ //! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export //! watermark falls back to it there. Native builds embed every font. use std::fmt::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; fn main() { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + let mut embedded = 0; + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) { match craft_fonts(&dir) { - Ok(entries) => src.push_str(&entries), + Ok(entries) => { + embedded = entries.lines().count(); + src.push_str(&entries); + } Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); } @@ -24,14 +28,37 @@ fn main() { } } src.push_str("];\n"); + // Without them the app still runs and every language still selectable, so Chinese and Japanese + // just paint boxes — the one failure a user cannot diagnose from what they see. + if embedded == 0 { + println!( + "cargo::warning=no craft-fonts embedded: Chinese and Japanese text will render as empty boxes. \ + Clone https://github.com/storytold/craft-fonts next to this repo and rebuild with \ + CRAFT_FONTS_DIR=../craft-fonts" + ); + } let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); if let Err(e) = std::fs::write(&out, src) { println!("cargo::error=writing {}: {e}", out.display()); } } +/// Resolve a relative `CRAFT_FONTS_DIR` against both roots a caller might mean: a build script's +/// cwd is the *package* root (`crates/engine`), while the `../craft-fonts` the README and +/// craft-fonts' docs use is written relative to the *workspace* root and would otherwise resolve +/// to `crates/craft-fonts`. +fn resolve(dir: PathBuf) -> PathBuf { + if dir.is_absolute() { + return dir; + } + let package = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()); + let workspace = package.parent().and_then(Path::parent).unwrap_or(package.as_path()); + let candidates = [workspace.join(&dir), package.join(&dir)]; + candidates.iter().find(|candidate| candidate.is_dir()).cloned().unwrap_or_else(|| candidates[0].clone()) +} + /// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { +fn craft_fonts(dir: &Path) -> Result { let manifest = dir.join("fonts/manifest.txt"); println!("cargo::rerun-if-changed={}", manifest.display()); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; From a15bd574f9b3dc6462e7b7ff9e6c077cbd83045f Mon Sep 17 00:00:00 2001 From: NDsans Date: Thu, 8 Oct 2026 03:08:33 +0800 Subject: [PATCH 11/19] LR-BEHAV-LOCALIZE: fill the zh-hans catalog to match zh-hant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh-hant carries 1246 messages; zh-hans had 1029, so 217 recently added strings — mask component editing, point colour samples, spot removal, colour-label sets, smart-preview status, memory budget, the CLI/MCP command list and the eighteen stock presets — fell back to English in the Simplified Chinese UI. Translate them and order the file like zh-hant so the two catalogs can be compared line by line. --- crates/ui-egui/locales/zh-hans.json | 375 ++++++++++++++++++++++------ docs/localization-zh-hans.md | 8 +- 2 files changed, 301 insertions(+), 82 deletions(-) diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7dc4d7c3..0d94dcad 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -5,9 +5,13 @@ "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", "About LightCraft": "关于 LightCraft", "Activity": "活动", + "Actual size": "实际大小", "Add": "添加", "Add Catchlight": "添加眼神光", "Add Folder to Local": "将文件夹添加到本地", + "Add Point Color Sample": "添加点颜色样本", + "Add Red Eye Correction": "添加红眼校正", + "Add Remove Spot": "添加/移除污点", "Add Selected Photos": "添加所选照片", "Add a rule (⌥-click: a group)": "添加条件(按 ⌥ 单击:添加组)", "Add in place": "在原位置添加", @@ -19,9 +23,11 @@ "Add to Favorites": "添加到收藏夹", "Add to My Photos": "添加到我的照片", "Add to Selected Photos": "添加到所选照片", + "Add to Target Album": "添加到目标相册", "Add/Subtract/Intersect Mask": "添加/减去/相交蒙版", "Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。", "Adding": "正在添加", + "Advanced search…": "高级搜索…", "After": "修改后", "After 30 days": "30 天后", "After 7 days": "7 天后", @@ -40,12 +46,19 @@ "Amount": "数量", "Aperture": "光圈", "Application": "应用程序", + "Apply Color Label Set": "应用颜色标签集", + "Apply Filter Preset": "应用筛选器预设", + "Apply Metadata Preset": "应用元数据预设", + "Apply Point Curve Preset": "应用点曲线预设", "Apply Preset": "应用预设", + "Apply Settings JSON": "应用设置 JSON", "Approved": "已批准", "Aqua": "青色", "Aqua Hue": "青色色相", "Aqua Luminance": "青色明度", "Aqua Saturation": "青色饱和度", + "ArtCraft Website": "ArtCraft 网站", + "ArtCraft — more creative apps": "ArtCraft — 更多创意应用", "As Shot": "拍摄时设置", "As shot": "拍摄时设置", "Ascending": "升序", @@ -60,38 +73,50 @@ "Auto Advance after rating or flagging": "评分或加旗标后自动前进", "Auto Advance: move to the next photo after rating or flagging": "自动前进:评分或加旗标后移到下一张照片", "Auto Align": "自动对齐", + "Auto B&W Mix": "自动黑白混合", "Auto Crop": "自动裁剪", "Auto Import": "自动导入", + "Auto Import Now": "立即自动导入", + "Auto Import Settings": "自动导入设置", "Auto Mask": "自动蒙版", "Auto Settings": "自动设置", + "Auto Straighten": "自动拉直", "Auto Sync": "自动同步", "Auto-Stack by Capture Time": "按拍摄时间自动堆叠", "Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…", + "Auto-Tag Photos from Tracklog": "从轨迹日志自动标记照片", "Auto-Tag from Tracklog": "从轨迹日志自动标记", "Auto-Tag from Tracklog…": "从轨迹日志自动标记…", "Automatic": "自动", "Automatically Write Changes into XMP": "自动将更改写入 XMP", "Automatically write changes into XMP sidecars": "自动将更改写入 XMP 附属文件", + "Autumn Gold": "秋日金黄", "B&W": "黑白", "B&W Mix": "黑白混合", "B&W Mixer": "黑白混色器", "Back": "返回", + "Back Up Library…": "备份图库…", "Back to Edit": "返回编辑", "Back to Edit (E)": "返回编辑 (E)", "Back to Grid": "返回网格视图", "Background": "背景", "Balance": "平衡", + "Basic": "基础", "Before": "修改前", "Before/After (Y)": "修改前/修改后 (Y)", "Before/After Settings": "修改前/修改后设置", "Before/After Split": "修改前/修改后分屏", "Before/After Split Top/Bottom": "修改前/修改后上下分屏", "Before/After Top/Bottom": "修改前/修改后上下排列", + "Begin Interaction": "开始交互", "Bit depth": "位深度", "Black & White": "黑白", "Blacks": "黑色色阶", + "Bleach Bypass": "漂白保留", + "Bleached": "漂白", "Blending": "混合", "Blue": "蓝色", + "Blue Hour Boost": "蓝调时刻增强", "Blue Hue": "蓝色色相", "Blue Luminance": "蓝色明亮度", "Blue Primary": "蓝色原色", @@ -100,11 +125,14 @@ "Blue/Yellow Fringe": "蓝/黄边缘", "Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。", "Boundary Warp": "边界变形", + "Bright & Airy": "明亮通透", "Bring All to Front": "全部置于顶层", + "Browse Folder": "浏览文件夹", "Browse Folder…": "浏览文件夹…", "Browse Profiles": "浏览配置文件", "Browse…": "浏览…", "Brush": "画笔", + "Brush Settings": "画笔设置", "Brush Stroke": "画笔笔触", "Build Previews": "构建预览", "Build Smart Previews": "构建智能预览", @@ -115,26 +143,36 @@ "Camera": "相机", "Cameras and Cards": "相机和存储卡", "Cancel": "取消", + "Cancel Interaction": "取消交互", + "Cancel Preview Build": "取消生成预览", "Candidate": "候选", "Caption": "题注", "Capture Date": "拍摄日期", "Captured": "拍摄时间", + "Catalog Statistics": "目录统计", "Check All": "全部选中", + "Check Output Path": "检查输出路径", + "Choose Another Library…": "选择其他图库…", "Choose Edit Settings to Copy": "选择要复制的编辑设置", "Choose Edit Settings to Copy…": "选择要复制的编辑设置…", "Choose Folder…": "选择文件夹…", "Choose a photo in the grid or filmstrip": "请在网格视图或胶片窗格中选择一张照片", "Choose…": "选择…", + "Cinematic": "电影感", "City": "城市", "Clarity": "清晰度", + "Clean Studio": "纯净棚拍", "Clear": "清除", "Clear Cache": "清除缓存", "Clear Filters": "清除过滤器", "Clear Guides": "清除参考线", "Clear History": "清除历史记录", + "Clear Preview Cache": "清除预览缓存", "Clear Quick Collection": "清空快速集合", "Clear Reference": "清除参考照片", "Clear all": "全部清除", + "Clear form": "清除表单", + "Cleared History": "已清除历史记录", "Click Zoom Ratio": "单击缩放比例", "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。", "Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束", @@ -144,26 +182,38 @@ "Close": "关闭", "Close Panel": "关闭面板", "Close Window": "关闭窗口", + "Close all": "全部关闭", + "Close file": "关闭文件", "Cloudy": "阴天", "Collapse All Stacks": "折叠所有堆叠", "Color": "颜色", "Color Grading": "颜色分级", + "Color Label Names": "颜色标签名称", + "Color Label Sets": "颜色标签集", "Color Mixer": "颜色混合器", "Color Noise Reduction": "颜色降噪", "Color Priority": "颜色优先", "Color Range": "颜色范围", "Color space": "色彩空间", "Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)", + "Combine files…": "合并文件…", + "Command Journal": "命令日志", + "Comments panel": "注释面板", "Compare": "比较", "Compare (Shift+C)": "比较(Shift+C)", "Compare Before and After": "比较修改前和修改后", "Compare needs at least two photos": "比较至少需要两张照片", "Component options": "组件选项", "Compression": "压缩", + "Concrete Cool": "冷调混凝土", "Confirm before deleting": "删除前确认", "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", "Constrain Crop": "限制裁剪", + "Continue Without Saving": "不保存继续", "Contrast": "对比度", + "Convert to DNG": "转换为 DNG", + "Cool Fade": "冷调褪色", + "Cool Morning": "清冷晨光", "Copy": "复制", "Copy After's Settings to Before": "将修改后的设置复制到修改前", "Copy Before's Settings to After": "将修改前的设置复制到修改后", @@ -172,6 +222,7 @@ "Copy Metadata": "复制元数据", "Copy as DNG": "拷贝为 DNG", "Copy into the library (else use the files where they are)": "拷贝到图库中(否则使用文件当前位置)", + "Copy pages": "复制页面", "Copy the files (into the library's Originals/, or a folder you choose)": "拷贝文件(到图库的 Originals/ 或您选择的文件夹)", "Copy to": "拷贝到", "Copy to Clipboard": "拷贝到剪贴板", @@ -185,6 +236,9 @@ "Create Folder": "创建文件夹", "Create Folder…": "创建文件夹…", "Create New Mask": "创建新蒙版", + "Create PDF from clipboard": "从剪贴板创建 PDF", + "Create PDF from file…": "从文件创建 PDF…", + "Create PDF from images…": "从图像创建 PDF…", "Create Preset": "创建预设", "Create Preset…": "创建预设…", "Create Proof Copy": "创建校样副本", @@ -196,18 +250,34 @@ "Create Version from Step": "从该步骤创建版本", "Create Virtual Copy": "创建虚拟副本", "Creator": "创建者", + "Crisp Landscape": "清晰风光", + "Crisp Vista": "通透远景", "Crop": "裁剪", "Crop & Rotate": "裁剪和旋转", "Crop & Rotate (C)": "裁剪和旋转(C)", + "Crop Aspect": "裁切长宽比", + "Cross Process": "交叉冲洗", "Culling": "挑片", "Curve": "曲线", "Custom": "自定义", + "Custom…": "自定义…", + "Cut": "剪切", + "Cut pages": "剪切页面", + "Cycle Crop Overlay": "切换裁切叠加", + "Cycle Crop Overlay Orientation": "切换裁切叠加方向", + "Cycle Info Overlay": "切换信息叠加", + "Cycle Mask Overlay Color": "切换蒙版叠加颜色", + "Cycle Mask Overlay Mode": "切换蒙版叠加模式", "Cylindrical": "圆柱形", + "Dark & Moody": "暗调氛围", "Darken": "压暗", "Darks": "暗部", "Date Groups": "日期分组", "Day": "日", "Daylight": "日光", + "Decrease Brush Feather": "减小画笔羽化", + "Decrease Brush Size": "减小画笔尺寸", + "Deep Sky": "深邃天空", "Defringe": "去边", "Deghost Amount": "去伪影量", "Dehaze": "去朦胧", @@ -215,12 +285,22 @@ "Delete (⌫)": "删除 (⌫)", "Delete Album": "删除相册", "Delete All Masks": "删除所有蒙版", + "Delete Color Label Set": "删除颜色标签集", + "Delete Export Preset": "删除导出预设", + "Delete Filter Preset": "删除筛选器预设", + "Delete Imported Profile": "删除导入的配置文件", "Delete Keyword": "删除关键字", + "Delete Keyword Set": "删除关键字集", "Delete Mask": "删除蒙版", + "Delete Metadata Preset": "删除元数据预设", "Delete Permanently": "永久删除", "Delete Photo": "删除照片", "Delete Photos": "删除照片", + "Delete Point Color Sample": "删除点颜色样本", + "Delete Point Curve Preset": "删除点曲线预设", "Delete Preset": "删除预设", + "Delete Red Eye Correction": "删除红眼校正", + "Delete Spot": "删除污点", "Delete Version": "删除版本", "Delete all": "删除全部", "Delete all spots": "删除所有污点", @@ -229,13 +309,18 @@ "Density": "密度", "Depth Range": "深度范围", "Descending": "降序", + "Deselect": "取消选择", "Deselect All": "取消全选", + "Desert Heat": "沙漠热浪", + "Desert Warmth": "沙漠暖意", "Detail": "细节", "Detail (D)": "细节 (D)", "Diagonal": "对角线", "Discard Preview Cache": "丢弃预览缓存", "Display P3": "Display P3", + "Display theme": "显示主题", "Distortion": "扭曲", + "Document properties…": "文档属性…", "Don't enlarge": "不放大", "Done": "完成", "Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正", @@ -250,25 +335,37 @@ "Edit Capture Time": "编辑拍摄时间", "Edit Capture Time…": "编辑拍摄时间…", "Edit Color Label Names": "编辑颜色标签名称", + "Edit Color Label Names…": "编辑颜色标签名称…", + "Edit Copy for External Editor": "编辑外部编辑器副本", + "Edit Info": "编辑信息", "Edit Label Names…": "编辑标签名称…", + "Edit Mask Component": "编辑蒙版组件", "Edit Sections": "编辑板块", "Edit Smart Album": "编辑智能相册", "Edit Smart Album…": "编辑智能相册…", + "Edit Spot": "编辑污点", "Edit in External Editor": "在外部编辑器中编辑", "Edited": "已编辑", + "Effect": "效果", "Effects": "效果", "Enable Lens Corrections": "启用镜头校正", + "End Interaction": "结束交互", + "English": "English", "Enter Full Screen": "进入全屏", "Erase": "擦除", "Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段", "Existing previews": "现有预览", + "Exit": "退出", "Expand All Stacks": "展开所有堆叠", + "Expand/Collapse Stack": "展开/折叠堆叠", "Export": "导出", "Export Album with Preset": "使用预设导出相册", "Export Album…": "导出相册…", + "Export As…": "导出为…", "Export Group…": "导出分组…", "Export Now": "立即导出", "Export Point Curve Presets": "导出点曲线预设", + "Export Point Curve Presets…": "导出点曲线预设…", "Export Presets": "导出预设", "Export Presets…": "导出预设…", "Export User Presets…": "导出用户预设…", @@ -280,6 +377,11 @@ "Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)", "Extended Description": "扩展描述", "External Editor": "外部编辑器", + "Faded Matte": "褪色哑光", + "Faded Slide": "褪色正片", + "Faded Urban": "褪色都市", + "Favorite Preset": "收藏预设", + "Favorite Profile": "收藏配置文件", "Favorites": "收藏夹", "Feather": "羽化", "File": "文件", @@ -289,17 +391,25 @@ "File Size": "文件大小", "File name": "文件名", "Fill Edges": "填充边缘", + "Film": "胶片", "Filmstrip": "胶片窗格", + "Filmstrip (/)": "胶片窗格 (/)", "Filter": "过滤器", "Filter Bar": "过滤器栏", "Filter Presets": "过滤器预设", "Filter bar": "过滤器栏", "Filter fields": "过滤字段", "Find Dust Spots": "查找灰尘污点", + "Find Missing Photos": "查找缺失的照片", "Find Missing Photos…": "查找丢失的照片…", "Find Similar Photos": "查找相似照片", + "Find tools and commands…": "查找工具和命令…", + "Find…": "查找…", "Fit": "适合", "Fit (F)": "适合 (F)", + "Fit on Screen": "适合屏幕", + "Fit to width": "适合宽度", + "Fit visible": "适合可见区域", "Flag": "旗标", "Flag as Pick": "标记为留用", "Flag as Reject": "标记为排除", @@ -316,21 +426,32 @@ "Folder name (renamed on disk; its photos follow)": "文件夹名称 (在磁盘上重命名;照片随之更新)", "Folders": "文件夹", "Folders and {date} use the capture time; a photo without one uses today's date.": "文件夹和{date}使用拍摄时间;没有拍摄时间的照片使用今天的日期。", + "Food": "美食", + "Forget Unchanged Local Photos": "忽略未修改的本地照片", "Forget unchanged photos": "忘记未更改的照片", + "Form fields panel": "表单字段面板", "Format": "格式", "Forward": "前进", "Free": "自由", + "Fresh & Bright": "清新明亮", + "Friday": "星期五", "Full": "完整", "Full Screen Preview": "全屏预览", + "Full screen mode": "全屏模式", "Full size": "完整大小", "GPS Track Log": "GPS 轨迹日志", + "GPU Rendering": "GPU 渲染", "General": "常规", "Geometry": "几何", + "Get Develop Settings": "获取编辑设置", "Global Hue": "全局色相", "Global Luminance": "全局明亮度", "Global Saturation": "全局饱和度", "Glossy": "光面纸", + "Go to History Step": "跳转到历史步骤", "Golden": "黄金比例", + "Golden Glow": "金色光晕", + "Golden Hour": "黄金时刻", "Grain": "颗粒", "Graphic": "图形", "Green": "绿色", @@ -344,7 +465,9 @@ "Green channel": "绿色通道", "Grid": "网格视图", "Grid Info": "网格信息", + "Gritty Street": "粗粝街景", "Group": "分组", + "Group by Date: Automatic": "按日期分组:自动", "Group by Day": "按日分组", "Group by Month": "按月分组", "Group by Year": "按年分组", @@ -352,9 +475,12 @@ "Group into Stack": "合并为堆叠", "Guided": "引导式", "Guided Upright": "引导式透视校正", + "Guided Upright Guides": "引导式垂直校正参考线", "HDR Merge": "HDR 合并", "HDR Panorama Merge": "HDR 全景合并", + "HDR Panorama with Last Settings": "使用上次设置合并 HDR 全景", "HDR Panorama…": "HDR 全景…", + "HDR with Last Settings": "使用上次设置合并 HDR", "HDR…": "HDR…", "Heal": "修复", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", @@ -366,6 +492,7 @@ "Hide Others": "隐藏其他", "Hides this shortcut only; the folder and its photos stay as they are": "仅隐藏此快捷方式;文件夹及其中的照片保持不变", "High": "高", + "High Contrast B&W": "高对比黑白", "Highlight": "高光", "Highlights": "高光", "Highlights Hue": "高光色相", @@ -376,16 +503,20 @@ "History": "历史记录", "History & Activity (Y)": "历史记录与活动(Y)", "Horizontal": "水平", + "Horizontal text": "横排文字", "Hue": "色相", "Hue Range": "色相范围", "If file exists": "如果文件已存在", + "Image": "图像", "Import": "导入", "Import Date": "导入日期", "Import Photos": "导入照片", "Import Photos…": "导入照片…", "Import Point Curve Presets": "导入点曲线预设", + "Import Point Curve Presets…": "导入点曲线预设…", "Import Presets": "导入预设", "Import Presets…": "导入预设…", + "Import Profiles": "导入配置文件", "Import Profiles & Presets…": "导入配置文件与预设…", "Import from Device": "从设备导入", "Import from Folder…": "从文件夹导入…", @@ -398,15 +529,24 @@ "In use": "使用中", "In-memory session: these settings last until LightCraft quits.": "内存会话:这些设置仅在 LightCraft 退出前有效。", "Include subfolders": "包含子文件夹", + "Increase Brush Feather": "增大画笔羽化", + "Increase Brush Size": "增大画笔尺寸", "Info": "信息", "Info (I)": "信息(I)", "Info overlay": "信息叠加显示", + "Inspect Photo": "检查照片", + "Instant ’70s": "70 年代即时成像", "Interface": "界面", + "Interface language": "界面语言", "Intersect": "相交", "Intersect with": "相交于", "Into one folder": "合并到一个文件夹", "Invert": "反相", "Invert Mask": "反相蒙版", + "JPEG (Large)": "JPEG(大)", + "JPEG (Small)": "JPEG(小)", + "Join the ArtCraft Discord": "加入 ArtCraft Discord", + "Join the ArtCraft Discord…": "加入 ArtCraft Discord…", "Keep in Local": "保留在本地", "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。缩略图缓存仍保留在图库中。", "Keyboard Shortcuts": "键盘快捷键", @@ -418,6 +558,7 @@ "Keyword Suggestions": "关键字建议", "Keyword to paint": "要涂抹的关键字", "Keywords": "关键字", + "Keywords (K)": "关键字 (K)", "Keywords you add appear here; ⌥1–⌥9 apply them.": "您添加的关键字会显示在此处;按 ⌥1–⌥9 即可应用。", "Landscape": "横向", "Language": "语言", @@ -425,29 +566,42 @@ "Last": "上次", "Last view": "上次视图", "Lateral Chromatic Aberration": "横向色差", + "Layer": "图层", + "Layers": "图层", "Leave them": "保留", "Lens": "镜头", "Lens Corrections": "镜头校正", "Level": "水平", "Library": "图库", + "Library Info": "图库信息", "Library Originals": "图库原始文件", + "Library Preferences": "图库偏好设置", + "Library State": "图库状态", "Light": "亮度", "LightCraft Default": "LightCraft 默认", "LightCraft Help": "LightCraft 帮助", "LightCraft Preset": "LightCraft 预设", "LightCraft Presets": "LightCraft 预设", "LightCraft Website": "LightCraft 网站", + "LightCraft couldn't find where to keep your library.": "LightCraft 找不到保存图库的位置。", + "LightCraft couldn't open the library at": "LightCraft 无法打开以下位置的图库:", "LightCraft on GitHub": "GitHub 上的 LightCraft", "LightCraft website": "LightCraft 网站", "LightCraft — Second Window": "LightCraft — 第二窗口", "Lights": "高光", "Limit file size (KB, 0 = off)": "限制文件大小(KB,0 = 关闭)", "Linear Gradient": "线性渐变", + "List Albums": "列出相册", + "List Develop Controls": "列出编辑控制项", + "List History": "列出历史记录", + "List Presets": "列出预设", + "List Profiles": "列出配置文件", "List this folder in Local from now on": "以后在本地中列出此文件夹", "Local": "本地", "Local folders": "本地文件夹", "Local library — no cloud account needed": "本地图库 — 无需云账户", "Locate Missing File…": "定位缺失的文件…", + "Locate Photo": "定位照片", "Location": "位置", "Long Edge": "长边", "Looking for photos…": "正在查找照片…", @@ -455,19 +609,26 @@ "Low": "低", "Luminance": "明度", "Luminance Range": "明度范围", + "Lush Greens": "浓郁绿意", "Magenta": "品红色", "Magenta Hue": "品红色色相", "Magenta Luminance": "品红色明度", "Magenta Saturation": "品红色饱和度", "Make Candidate the Select": "将候选设为选择", "Manual": "手动", + "Marquee zoom": "框选缩放", "Masking": "蒙版", "Masking (M)": "蒙版 (M)", "Masks": "蒙版", "Match": "匹配", + "Match Total Exposures": "匹配总曝光量", "Matte": "哑光", + "Matte Soft": "柔和哑光", "Med": "中", + "Medium Contrast": "中等对比度", "Megapixels": "百万像素", + "Memory Budget": "内存预算", + "Memory Usage": "内存用量", "Memory for caches": "缓存内存", "Menu": "菜单", "Merge": "合并", @@ -486,29 +647,47 @@ "Midtones split": "中间调分割点", "Minimize": "最小化", "Missing Photos": "缺失的照片", + "Misty Morning": "薄雾清晨", "Mode": "模式", "Modified Date": "修改日期", + "Monday": "星期一", + "Mono Red Filter": "黑白红色滤镜", + "Mono Rich": "浓郁黑白", + "Mono Soft": "柔和黑白", "Monochrome": "黑白", "Month": "月", + "Moody": "情绪氛围", + "Moody Portrait": "情绪人像", "More": "更多", "More preset options": "更多预设选项", "Move": "移动", + "Move Album": "移动相册", "Move Down in Stack": "在堆叠中下移", + "Move Folder": "移动文件夹", "Move Folder To…": "将文件夹移动到…", + "Move Mask": "移动蒙版", + "Move Preset to Group": "将预设移动到分组", "Move Up in Stack": "在堆叠中上移", "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "移动文件 (移到图库的 Originals/,或你选择的文件夹),并从源位置移除", "Move them": "移动它们", "Move to": "移动到", "Move to Group": "移动到组", "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "移动:文件将移动到下面的文件夹;每个原始文件 (及其 XMP 附属文件) 仅在副本校验通过后才会从源位置移除。重复的文件和失败的文件保留在原处。", + "Muted": "低饱和", + "Muted Film": "低饱和胶片", "My Photos": "我的照片", + "My Photos Panel": "我的照片面板", "Name": "名称", "Named": "已命名", "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "名称会显示在标签菜单、筛选栏和信息面板中,并写入 XMP。留空 = 颜色的名称。", "Navigator": "导航器", "Needs Work": "待处理", + "Neon Dusk": "霓虹暮色", + "Neon Night": "霓虹之夜", "Neutral": "中性", "Never": "从不", + "New": "新建", + "New Album": "新建相册", "New Album…": "新建相册…", "New Folder…": "新建文件夹…", "New Group…": "新建组…", @@ -517,7 +696,11 @@ "New Smart Album…": "新建智能相册…", "New album": "新建相册", "New album…": "新建相册…", + "New blank PDF": "新建空白 PDF", "New name": "新名称", + "New…": "新建…", + "Next Photo": "下一张照片", + "Night Blue": "夜色蓝调", "No": "否", "No Camera or Card Found": "未找到相机或存储卡", "No Date Groups": "不按日期分组", @@ -551,6 +734,7 @@ "None": "无", "Nothing to compare": "没有可比较的照片", "Nothing to survey": "没有可筛选的照片", + "Nudge Develop Value": "微调编辑数值", "OK": "确定", "Object": "对象", "Off": "关闭", @@ -563,13 +747,18 @@ "Open in": "打开方式", "Open in Detail": "在放大视图中打开", "Open the place in OpenStreetMap": "在 OpenStreetMap 中打开该地点", + "Open…": "打开…", "Optics": "镜头校正", + "Optimize Library": "优化图库", "Optional name": "名称(可选)", + "Options": "选项", "Orange": "橙色", "Orange Hue": "橙色色相", "Orange Luminance": "橙色明亮度", "Orange Saturation": "橙色饱和度", + "Orientation": "方向", "Original": "原始比例", + "Original + Settings": "原始文件 + 设置", "Original files": "原始文件", "Original offline · editing the smart preview": "原图离线 · 正在编辑智能预览", "Original offline · no smart preview": "原图离线 · 无智能预览", @@ -577,22 +766,29 @@ "Other images (JPEG, PNG, TIFF, HEIC…)": "其他图像(JPEG、PNG、TIFF、HEIC…)", "Overlay": "叠加显示", "Overwrite": "覆盖", + "Pages": "页面", "Paint": "绘制", "Paint only areas like the one under the brush": "仅绘制与画笔下相似的区域", "Paint over a distraction on the photo to remove it.": "在照片上要移除的干扰物上涂抹。", "Paint overlay": "蒙版叠加显示", "Panorama Merge": "全景合并", + "Panorama with Last Settings": "使用上次设置合并全景", "Panorama…": "全景…", "Parametric curve": "参数曲线", + "Paste": "粘贴", "Paste Edit Settings": "粘贴编辑设置", "Paste Metadata": "粘贴元数据", "Paste Selected Settings": "粘贴所选设置", "Paste Selected Settings…": "粘贴所选设置…", + "Paste Settings from Previous": "粘贴上一张照片的设置", + "Paste pages": "粘贴页面", + "Pastel Haze": "粉彩薄雾", "Percent": "百分比", "Percentage": "百分比", "Performance": "性能", "Perspective": "透视", "Pet Eye": "宠物眼", + "Pet Eye Catchlight": "宠物眼神光", "Photo": "照片", "Photo Grid": "照片网格", "Photo Grid (G)": "照片网格(G)", @@ -613,6 +809,8 @@ "Point curve": "点曲线", "Portrait": "人像", "Position": "位置", + "Preferences": "首选项", + "Preferences…": "首选项…", "Preset": "预设", "Preset name": "预设名称", "Presets": "预设", @@ -620,16 +818,22 @@ "Presets (Shift+P)": "预设 (Shift+P)", "Presets ▾": "预设 ⌄", "Preview": "预览", + "Preview Build Progress": "预览生成进度", "Preview only": "仅预览", "Preview size": "预览大小", "Previews": "预览", + "Previous Photo": "上一张照片", "Print": "打印", + "Print…": "打印…", "Profile": "配置文件", + "Profile Browser": "配置文件浏览器", "Profile Distortion": "配置文件扭曲", + "Profile Menu": "配置文件菜单", "Profile Vignetting": "配置文件暗角", "Profile:": "配置文件:", "Profiles": "配置文件", "Projection": "投影", + "Properties": "属性", "Public domain": "公有领域", "Pupil Size": "瞳孔大小", "Purple": "紫色", @@ -641,8 +845,11 @@ "Purple Saturation": "紫色饱和度", "Put the locations you removed from Local back (no files change)": "恢复你从本地移除的位置 (不更改文件)", "Quality": "品质", + "Query Photos": "查询照片", "Quick Collection": "快速集合", "Quick Develop": "快速编辑", + "Quiet Green": "静谧绿", + "Quit": "退出", "Quit LightCraft": "退出 LightCraft", "Radial Gradient": "径向渐变", "Radius": "半径", @@ -655,6 +862,7 @@ "Raw photos": "RAW 照片", "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "将 RAW 照片导出为内嵌编辑设置的 DNG。大小、颜色和输出选项不适用。", "Read Metadata from File": "从文件读取元数据", + "Read mode": "阅读模式", "Reading": "正在读取", "Reading folder…": "正在读取文件夹…", "Recently Added": "最近添加", @@ -671,10 +879,14 @@ "Reference": "参考", "Reference View": "参考视图", "Reference the files where they are": "在原地引用文件", + "Refine Mask Edges": "细化蒙版边缘", + "Refresh Source": "刷新源", "Refresh Source (/)": "刷新源 (/)", + "Reject": "排除", "Reject below focus": "排除对焦低于此值的照片", "Rejected": "已排除", "Rejects": "排除的照片", + "Reload from Disk": "从磁盘重新读取", "Remove": "移除", "Remove (H)": "移除 (H)", "Remove CA": "移除色差", @@ -692,11 +904,16 @@ "Remove this rule": "删除此规则", "Rename": "重命名", "Rename Album": "重命名相册", + "Rename Folder": "重命名文件夹", "Rename Folder…": "重命名文件夹…", "Rename Keyword": "重命名关键字", "Rename Keyword…": "重命名关键字…", + "Rename Mask": "重命名蒙版", "Rename Photos": "重命名照片", "Rename Photos…": "重命名照片…", + "Rename Preset": "重命名预设", + "Rename Preview": "重命名预览", + "Rename Template Tags": "重命名模板标签", "Rename Version": "重命名版本", "Rename…": "重命名…", "Rendering": "正在渲染", @@ -715,49 +932,74 @@ "Resize to": "调整大小为", "Resolution (ppi)": "分辨率 (ppi)", "Restore": "恢复", + "Restore Library from Backup…": "从备份恢复图库…", + "Restore Version": "恢复版本", "Retouch": "润饰", + "Revert": "恢复", "Review Import": "查看导入内容", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)", "Right-click a preset to delete it": "右键单击预设即可将其删除", "Rights Usage Terms": "使用条款", "Rotate": "旋转", + "Rotate Crop Aspect": "旋转裁切长宽比", "Rotate Left": "向左旋转", "Rotate Right": "向右旋转", "Roughness": "粗糙度", "Roundness": "圆度", "Same as raw default": "与 RAW 默认设置相同", + "Sample Mask Color": "取样蒙版颜色", "Sample a colour on the photo": "在照片上取样颜色", "Saturation": "饱和度", "Saturation Range": "饱和度范围", + "Saturday": "星期六", "Save": "保存", + "Save As…": "另存为…", + "Save Color Label Set": "保存颜色标签集", "Save Current Filter as Preset…": "将当前筛选器保存为预设…", "Save Current Keywords as Set…": "将当前关键字保存为集…", + "Save Export Preset": "保存导出预设", + "Save Filter Preset": "保存筛选器预设", + "Save Keyword Set": "保存关键字集", + "Save Metadata Preset": "保存元数据预设", + "Save Metadata Preset…": "保存元数据预设…", "Save Metadata to File": "将元数据保存到文件", + "Save Point Curve Preset": "保存点曲线预设", "Save Point Curve…": "保存点曲线…", "Save as Preset": "保存为预设", "Save as Smart Album…": "保存为智能相册…", "Save as set": "保存为集", + "Save as…": "另存为…", "Save preset": "保存预设", "Scale": "缩放", "Scanning": "正在扫描", "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。", "Screen": "屏幕", "Search Photos": "搜索照片", + "Search…": "搜索…", + "Seasons": "四季", "Second Window": "第二个窗口", "Select": "选择", "Select All": "全选", "Select Luminance Range": "选择亮度范围", + "Select Mask": "选择蒙版", + "Select Photos": "选择照片", + "Select Spot": "选择污点", "Select a photo to edit": "选择要编辑的照片", "Select by": "选择方式", "Select by Flag, Rating or Label": "按旗标、星级或颜色标签选择", "Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)", "Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)", "Selected": "已选择", + "Selenium Tone": "硒调色", "Send Feedback…": "发送反馈…", + "Sepia Tone": "棕褐色调", "Services": "服务", + "Set Before": "设为修改前", "Set Color Label": "设置颜色标签", + "Set Crop": "设置裁切", "Set Develop Values": "设置编辑参数", "Set Flag": "设置旗标", + "Set Mask Adjustments": "设置蒙版调整", "Set Point Curve": "设置点曲线", "Set Profile": "设置配置文件", "Set Rating": "设置星级", @@ -807,24 +1049,35 @@ "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", "Show the Navigator while zoomed in": "放大时显示导航器", "Show the template tags; click one to insert it": "显示模板标签;单击即可插入", + "Show/Hide Mask": "显示/隐藏蒙版", "Show/hide My Photos panel": "显示/隐藏我的照片面板", "Shutter speed": "快门速度", + "Side panels": "侧面板", "Sidecar names": "附属文件名", "Size": "大小", "Size (% of short edge)": "大小 (占短边 %)", "Size limit": "大小限制", "Skip": "跳过", "Sky": "天空", + "Slideshow": "幻灯片播放", "Smart Album": "智能相册", + "Smart Album Rule Fields": "智能相册规则字段", + "Smart Preview Status": "智能预览状态", + "Smart Previews Location": "智能预览位置", "Smart previews": "智能预览", "Smoothness": "平滑度", + "Soft B&W": "柔和黑白", + "Soft Pastel": "柔和粉彩", "Soft Proofing": "软打样", + "Soft Skin": "柔和肤质", "Sort": "排序", "Sort by": "排序方式", "Source": "来源", "Source code on GitHub": "GitHub 上的源代码", "Spherical": "球面", "Spiral": "螺旋", + "Split Stack": "拆分堆叠", + "Spring Fresh": "春日清新", "Square Grid": "正方形网格", "Square Grid (G toggles)": "正方形网格(按 G 切换)", "Square Grid: show file names and formats": "正方形网格:显示文件名和格式", @@ -842,62 +1095,87 @@ "Stopping…": "正在停止…", "Straighten": "拉直", "Straighten Tool": "拉直工具", + "Strong Contrast": "强烈对比度", "Style": "样式", "Subfolder": "子文件夹", "Subfolder name": "子文件夹名称", "Subject": "主题", "Subtract": "减去", "Suggestions": "建议", + "Summer Haze": "夏日薄雾", + "Sunday": "星期日", "Survey": "筛选视图", "Survey (N)": "筛选视图(N)", "Swap": "交换", "Swap Aspect (X)": "交换长宽比(X)", "Swap Before and After Settings": "交换修改前和修改后的设置", + "Swap Compare Photos": "交换对比照片", + "Switch light / dark theme": "切换浅色/深色主题", "Sync Settings": "同步设置", "System Info": "系统信息", "System Info…": "系统信息…", "System default": "系统默认", "Tags": "标签", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…", + "Take a snapshot": "创建快照", + "Targeted Adjustment": "局部调整", "Targeted adjustment: drag up/down on the photo to adjust the colours there": "局部调整:在照片上上下拖动以调整该处的颜色", "Targeted adjustment: drag up/down on the photo to adjust the curve there": "局部调整:在照片上上下拖动以调整该处的曲线", + "Teal & Amber": "青绿与琥珀", + "Teal & Orange": "青绿与橙", "Temp": "色温", "Temperature": "色温", "Template": "模板", + "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", "Text": "文字", + "Text direction": "文字方向", "Texture": "纹理", "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留原始文件不变,每个文件附带一个保存其编辑内容的 XMP 附属文件。", "The photo has these settings": "此照片具有这些设置", + "Theme": "主题", "They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。", "Thirds": "三分法", "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。", "This photo can't be opened": "无法打开此照片", "Threshold": "阈值", "Thumbnail cache": "缩略图缓存", + "Thursday": "星期四", "Tint": "色调", "Title": "标题", + "Toggle Keyword from Set": "切换关键字集中的关键字", + "Toggle Section": "切换板块", + "Toggle Zoom": "切换缩放", "Tone Curve": "色调曲线", "Tools": "工具", "Top Level": "顶层", "Transfer": "传输", "Treatment": "处理方式", "Triangle": "三角形", + "Try Again": "重试", + "Tuesday": "星期二", "Tungsten": "钨丝灯", "Turn Off": "关闭", "Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹", + "Type": "类型", "Uncheck All": "全部取消选中", "Undo": "撤销", "Unedited": "未编辑", "Unflag": "取消旗标", "Unflagged": "无旗标", "Ungroup Stack": "取消堆叠", + "Unknown Date": "未知日期", "Unrated": "未评级", "Unreadable": "无法读取", "Update": "更新", + "Update Mask Shape": "更新蒙版形状", + "Update Preset with Current Settings": "使用当前设置更新预设", "Update Rules from Current Filter": "从当前筛选器更新规则", "Update Version": "更新版本", + "Update Version with Current Settings": "使用当前设置更新版本", "Update with Current Settings": "使用当前设置更新", "Upright": "垂直校正", + "Urban": "都市", + "Use Keyword Set": "使用关键字集", "Use Library Folder": "使用图库文件夹", "Use camera-specific defaults": "使用相机专属默认值", "Use the GPU for rendering": "使用 GPU 渲染", @@ -909,17 +1187,27 @@ "Versions": "版本", "Versions (Shift+V)": "版本 (Shift+V)", "Vertical": "垂直", + "Vertical text": "竖排文字", "Vibrance": "鲜艳度", "Videos": "视频", "View": "视图", "Vignette": "暗角", "Vignetting": "暗角", + "Vintage": "复古", + "Virtual Copy": "虚拟副本", + "Visible": "可见照片", "Visualize Spots": "可视化斑点", "Visualize Spots (A)": "可视化斑点 (A)", "Visualize range": "显示范围", "Vivid": "鲜艳", + "Vivid Pop": "明快跳色", + "Warm Film": "暖调胶片", + "Warm Glow": "暖光", + "Warm Print": "暖调印相", + "Warm Table": "暖调餐桌", "Watched folder": "监视的文件夹", "Watermark": "水印", + "Wednesday": "星期三", "What's New": "新功能", "When rated or hovered": "评级或悬停时", "White Balance": "白平衡", @@ -931,6 +1219,8 @@ "Width (px)": "宽度 (px)", "Width × Height": "宽度 × 高度", "Window": "窗口", + "Winter Blue": "冬日蓝调", + "XMP Sidecar Preferences": "XMP 附属文件偏好设置", "XMP sidecars": "XMP 附属文件", "Year": "年", "Yellow": "黄色", @@ -938,94 +1228,21 @@ "Yellow Luminance": "黄色明度", "Yellow Saturation": "黄色饱和度", "Yes": "是", + "Your library couldn't be opened": "无法打开图库", "Zoom": "缩放", "Zoom 100%": "缩放 100%", "Zoom In": "放大", "Zoom Out": "缩小", "Zoom to Fit": "适合窗口", + "Zoom to page level": "缩放至页面级别", "and": "和", "comma, separated": "逗号分隔", "memory only": "仅内存", "none": "无", "not available here": "此处不可用", "of the following rules:": "以下规则:", - "Effect": "效果", - "Image": "图像", - "Layer": "图层", - "Type": "类型", - "Save As…": "另存为…", - "Exit": "退出", - "New…": "新建…", - "New": "新建", - "Orientation": "方向", - "Layers": "图层", - "Properties": "属性", - "Brush Settings": "画笔设置", - "Options": "选项", - "Fit on Screen": "适合屏幕", - "Cut": "剪切", - "Paste": "粘贴", - "Deselect": "取消选择", - "Export As…": "导出为…", - "Search…": "搜索…", - "Theme": "主题", - "Pages": "页面", - "Preferences": "首选项", - "Preferences…": "首选项…", - "Interface language": "界面语言", - "Open…": "打开…", - "New blank PDF": "新建空白 PDF", - "Create PDF from file…": "从文件创建 PDF…", - "Create PDF from images…": "从图像创建 PDF…", - "Create PDF from clipboard": "从剪贴板创建 PDF", - "Combine files…": "合并文件…", - "Save as…": "另存为…", - "Close file": "关闭文件", - "Close all": "全部关闭", - "Revert": "恢复", - "Print…": "打印…", - "Document properties…": "文档属性…", - "Find…": "查找…", - "Advanced search…": "高级搜索…", - "Copy pages": "复制页面", - "Cut pages": "剪切页面", - "Paste pages": "粘贴页面", - "Fit visible": "适合可见区域", - "Marquee zoom": "框选缩放", - "Take a snapshot": "创建快照", - "Full screen mode": "全屏模式", - "Read mode": "阅读模式", - "Switch light / dark theme": "切换浅色/深色主题", - "Comments panel": "注释面板", - "Form fields panel": "表单字段面板", - "Clear form": "清除表单", - "Find tools and commands…": "查找工具和命令…", - "Actual size": "实际大小", - "Zoom to page level": "缩放至页面级别", - "Fit to width": "适合宽度", - "Display theme": "显示主题", - "Side panels": "侧面板", - "Text direction": "文字方向", - "Horizontal text": "横排文字", - "Vertical text": "竖排文字", - "Your library couldn't be opened": "无法打开图库", - "LightCraft couldn't find where to keep your library.": "LightCraft 找不到保存图库的位置。", - "LightCraft couldn't open the library at": "LightCraft 无法打开以下位置的图库:", - "Try Again": "重试", - "Choose Another Library…": "选择其他图库…", - "Continue Without Saving": "不保存继续", - "Quit": "退出", - "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", - "Back Up Library…": "备份图库…", - "Restore Library from Backup…": "从备份恢复图库…", - "In Recently Deleted": "在“最近删除”中", - "Reload from Disk": "从磁盘重新读取", - "Monday": "星期一", - "Tuesday": "星期二", - "Wednesday": "星期三", - "Thursday": "星期四", - "Friday": "星期五", - "Saturday": "星期六", - "Sunday": "星期日", - "Unknown Date": "未知日期" + "this photo": "这张照片", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "In Recently Deleted": "在“最近删除”中" } diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index 002232b1..505ac269 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -1,13 +1,15 @@ # LightCraft 简体中文界面 -LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。 +LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日语。语言设置保存在 `ui.json` 的 `language` 字段中。 - 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。 -- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。 +- 已翻译的范围与 `zh-hant.json` 键集完全一致(1246 条),两份目录的键与键序一一对应。 - 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。 这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts); 使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。 - 不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。 + 不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框 —— + 语言菜单里四种语言仍然都在,选中「简体中文」也只会得到一屏方框。未内嵌字体时构建会打印一条 + `cargo::warning` 提示这一点。 - 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。 - 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。 - 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框 From 520d7b8616174b944795d19bc9e6cb7a21967145 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 15:40:18 -0400 Subject: [PATCH 12/19] Library: switching libraries no longer shows the old library's sidebar A catalog loaded from disk counts revisions from 0, like a new empty one, and the sidebar counts, keyword tree, date groups and grid list are cached by revision alone. Opening an empty library after one that was never edited this session therefore kept the old library's numbers. Each catalog loaded in a process now gets its own revision range. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/library.rs | 8 +++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_switch_library.rs | 43 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tests_switch_library.rs diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4382160a..88fa2f3f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -295,7 +295,13 @@ impl Session { { log::error!("library: {e}"); } - let (mut journal, catalog, report) = Journal::open(catalog)?; + let (mut journal, mut catalog, report) = Journal::open(catalog)?; + // A loaded catalog counts revisions from 0, like every other one. Caches (sidebar counts, + // keyword tree, the grid's list…) are keyed on the revision, so give each library loaded + // in this process its own range: otherwise an empty library opened after one that was + // never edited this session would look unchanged and show the old library's numbers. + static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32; self.catalog = catalog; self.undo.clear(); self.redo.clear(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4addbf3..a271bec9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,6 +42,8 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_switch_library; +#[cfg(test)] mod tests_unsaved; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs new file mode 100644 index 00000000..d7bffc0d --- /dev/null +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -0,0 +1,43 @@ +//! Switching libraries must not leave the old library's sidebar numbers, keyword tree or date +//! groups on screen. A library loaded from disk starts at catalog revision 0, and so does a brand +//! new empty one, so caches keyed on the revision alone would see "nothing changed". + +use serde_json::json; + +use crate::{LightcraftApp, Services}; + +#[test] +fn opening_another_library_drops_the_old_librarys_cached_panels() { + let dir = std::env::temp_dir().join(format!("lc-ui-switchlib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + + // library A with photos, reopened from disk (as at launch) + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&a, true).unwrap(); + session.close_library().unwrap(); + session.open_library(&a, false).unwrap(); + assert!(!session.catalog.is_empty()); + + let mut app = LightcraftApp::new(session, Services::default()); + let (cat, caches) = (&app.session.catalog, &mut app.caches); + assert!(caches.counts(cat).total > 0); + assert!(!caches.keyword_tree(cat).is_empty()); + assert!(!caches.date_groups(cat).is_empty()); + + // the grid's own list is cached too + assert!(!app.session.visible().is_empty()); + + app.run("app.openLibrary", json!({"path": b.to_string_lossy()})).unwrap(); + assert!(app.session.catalog.is_empty(), "library B is empty"); + let (cat, caches) = (&app.session.catalog, &mut app.caches); + assert_eq!(caches.counts(cat).total, 0, "All Photos count"); + assert_eq!(caches.counts(cat).picks, 0); + assert!(caches.keyword_tree(cat).is_empty(), "keywords"); + assert!(caches.date_groups(cat).is_empty(), "By Date"); + assert!(app.session.visible().is_empty(), "grid"); + + let _ = std::fs::remove_dir_all(&dir); +} From fb01966a0086333554b9c6662d513ce1a48a1b21 Mon Sep 17 00:00:00 2001 From: RicSchonfelder Date: Wed, 7 Oct 2026 17:03:20 -0300 Subject: [PATCH 13/19] i18n: add Brazilian Portuguese (pt-br) UI translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pt-br as a fifth interface language beside English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese. `crates/ui-egui/locales/pt-br.json` and `pt-br-formats.json` carry the messages and the format strings; the language table gains the entry (`PtBr, "pt-br", "Português (Brasil)", "Latn", ...`), and Edit > Language plus `app.language.portuguese` select it. Locale tags pt-BR, pt_BR.UTF-8 and pt all normalize to the same language. Translation follows pt-BR UI conventions (Adobe terms: revelação, balanço de branco, máscara, cópia virtual…); proper nouns and file formats stay as written (LightCraft, ArtCraft, GitHub, DNG, TIFF, XMP). Placeholders, format specs and punctuation match the English source; the English plural suffix is consumed with `{:.0}` where Portuguese does not need it. Tests: every_language_has_a_catalog_covering_the_source, catalogs_agree_on_placeholders_and_report_gaps, locale_tags_normalize and the painted-window test now cover pt-br. Docs: localization.md, a new localization-pt-br.md and the parity rows. Co-Authored-By: DeepSeek V4 Flash (opencode) --- crates/ui-egui/locales/pt-br-formats.json | 122 ++ crates/ui-egui/locales/pt-br.json | 1261 +++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 11 + crates/ui-egui/src/menus.rs | 2 + docs/localization-pt-br.md | 36 + docs/localization.md | 7 +- docs/parity.md | 4 +- xtask/src/parity.rs | 2 +- 8 files changed, 1439 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/locales/pt-br-formats.json create mode 100644 crates/ui-egui/locales/pt-br.json create mode 100644 docs/localization-pt-br.md diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json new file mode 100644 index 00000000..50b0f2ab --- /dev/null +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -0,0 +1,122 @@ +{ + " ({} in Recently Deleted)": " ({} em Excluídos recentemente)", + " · {skipped} skipped (file exists)": " · {skipped} ignorada(s) (arquivo existente)", + " · {} duplicate{} skipped": " · {} duplicada{:.0} ignorada", + " · {} failed: {}": " · {} falhou: {}", + " · {} moved": " · {} movida(s)", + " · {} not readable": " · {} não legível", + ", {failed} file{} not readable": ", {failed} arquivo{:.0} não legível", + "Add {local_n} to My Photos": "Adicionar {local_n} a Minhas fotos", + "Add {} Photo{}": "Adicionar {} foto{:.0}", + "Add “{}” to Favorites": "Adicionar “{}” aos favoritos", + "Added {n} photo{} to My Photos": "Adicionada {n} foto{:.0} a Minhas fotos", + "Added {n} photo{} to “{}”": "Adicionada {n} foto{:.0} a “{}”", + "Adding photos… {} of {}": "Adicionando fotos… {} de {}", + "All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {} foto{:.0} selecionada · clique com o botão direito ▸ Restaurar, ou Excluir permanentemente para importar de novo", + "Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {} foto{:.0} selecionada em Todas as fotos", + "Auto ({})": "Automático ({})", + "Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos", + "Automatic ({} MB)": "Automático ({} MB)", + "Build 1:1 Previews ({scope})": "Gerar pré-visualizações 1:1 ({scope})", + "Build Smart Previews ({scope})": "Gerar pré-visualizações inteligentes ({scope})", + "Build Standard-Sized Previews ({scope})": "Gerar pré-visualizações em tamanho padrão ({scope})", + "Click {}:1": "Clique em {}:1", + "Create {n} Virtual Copies": "Criar {n} cópias virtuais", + "Decoder: {reason}": "Decodificador: {reason}", + "Delete \"{label}\"": "Excluir \"{label}\"", + "Delete \"{label}\" (and the mask)": "Excluir \"{label}\" (e a máscara)", + "Delete {n} Photos": "Excluir {n} fotos", + "Delete “{current}”": "Excluir “{current}”", + "Discard Smart Previews ({scope})": "Descartar pré-visualizações inteligentes ({scope})", + "Duplicate \"{label}\"": "Duplicar \"{label}\"", + "Example: {example}": "Exemplo: {example}", + "Exported {n} photo{}": "Exportada {n} foto{:.0}", + "Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{:.0}", + "Exported {} point curve preset{}": "Exportadas {} predefinição{:.0} de curva de pontos", + "Exported {} preset{}": "Exportadas {} predefinição{:.0}", + "Exporting {} of {total}": "Exportando {} de {total}", + "Filter bar — {active} active filter{}": "Barra de filtros — {active} filtro{:.0} ativo", + "Healed {n} dust spot{}": "Recuperadas {n} mancha{:.0} de poeira", + "Import cancelled · {} photo{} added": "Importação cancelada · {} foto{:.0} adicionada", + "Imported {n} point curve preset{}": "Importadas {n} predefinição{:.0} de curva de pontos", + "Imported {n} preset{}": "Importadas {n} predefinição{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Importadas {n} predefinição{:.0}, {f} arquivo{:.0} não legível", + "Imported {} photo{}": "Importadas {} foto{:.0}", + "Label Set: {name}": "Conjunto de rótulos: {name}", + "Matches: {}": "Correspondências: {}", + "Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?", + "Named ({named_n})": "Nomeadas ({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{:.0} — somente os ajustes copiados são colados", + "Preset: {name}": "Predefinição: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Somente pré-visualização — editando o JPEG incorporado da câmera ({})", + "Preview only — {}": "Somente pré-visualização — {}", + "Profile: {}": "Perfil: {}", + "Proof Copy ({})": "Cópia de prova ({})", + "Proof Preview · {}": "Pré-visualização de prova · {}", + "Quick Develop · {n} photos": "Revelação rápida · {n} fotos", + "Quick Develop: {name}": "Revelação rápida: {name}", + "Rated {}": "Avaliada {}", + "Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}", + "Reading photos… {} of {}": "Lendo fotos… {} de {}", + "Redo {}": "Refazer {}", + "Remove “{}” from Favorites": "Remover “{}” dos favoritos", + "Rename {n} Photos…": "Renomear {n} fotos…", + "Rename “{name}”": "Renomear “{name}”", + "Rendering on the CPU: {why}": "Renderizando na CPU: {why}", + "Reset {name}": "Redefinir {name}", + "Reset {} Channel": "Redefinir canal {}", + "Saved export preset “{}”": "Predefinição de exportação “{}” salva", + "Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto", + "Showing {n} of {}": "Mostrando {n} de {}", + "Smart album: {}": "Álbum inteligente: {}", + "Undo {}": "Desfazer {}", + "Updated “{name}”": "“{name}” atualizado", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versão {} — uma biblioteca de fotos e revelador RAW em Rust puro, desenvolvida de forma independente.", + "Version: {}": "Versão: {}", + "none (CPU rendering): {why}": "nenhum (renderização na CPU): {why}", + "the {n} selected photos": "as {n} fotos selecionadas", + "the {} photos in view": "as {} fotos em exibição", + "these {count} photos": "estas {count} fotos", + "{count} smart preview{} · {:.1} MB": "{count} pré-visualizações{:.0} · {:.1} MB", + "{f} folders (and their subfolders)": "{f} pastas (e suas subpastas)", + "{label} Grading": "Graduação de {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} em cada foto selecionada", + "{label}, {n} photos": "{label}, {n} fotos", + "{matching} of {t} photos": "{matching} de {t} fotos", + "{matching} photos": "{matching} fotos", + "{mode} spot {} of {}": "{mode}: ponto {} de {}", + "{month}": "{month}", + "{month} {year}": "{month}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} com falha)", + "{name} Label": "Rótulo {name}", + "{name} Off": "{name} desativado", + "{name} On": "{name} ativado", + "{name} — double-click to reset it": "{name} — clique duas vezes para redefinir", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} alteração{:.0} salva na memória mas não gravada no disco: {e}\nO LightCraft tenta novamente automaticamente; encerrar agora perderia {}.", + "{n} found · {sel} selected": "{n} encontradas · {sel} selecionadas", + "{n} photos": "{n} fotos", + "{n} photos in {} are hidden by the filters above": "{n} fotos em {} estão ocultas pelos filtros acima", + "{n} photo{}": "{n} foto{:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} foto{:.0} correspondente(s) · atualiza automaticamente conforme as fotos mudam", + "{n} photo{} now · updates automatically as photos change": "{n} foto{:.0} agora · atualiza automaticamente conforme as fotos mudam", + "{sel_n} selected · {counted}": "{sel_n} selecionadas · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} foto{:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day}/{month}/{year}", + "{what} merge added": "{what} mesclagem adicionada", + "{what} merge failed: {e}": "{what} falha na mesclagem: {e}", + "{year}": "{year}", + "{} and higher": "{} ou superior", + "{} files": "{} arquivos", + "{} files and {f} folder{}": "{} arquivos e {f} pasta{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guias — arraste ao longo das linhas que devem ficar verticais ou horizontais.", + "{} photos": "{} fotos", + "{} photo{} not readable": "{} foto{:.0} não legível", + "{} spot(s) on this photo": "{} ponto(s) nesta foto", + "· {dups} already in the library": "· {dups} já na biblioteca", + "· {n} photo{}": "· {n} foto{:.0}", + "… and {more} more": "… e mais {more}" +} diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json new file mode 100644 index 00000000..503b2eda --- /dev/null +++ b/crates/ui-egui/locales/pt-br.json @@ -0,0 +1,1261 @@ +{ + "+ Group": "+ Grupo", + "+ Rule": "+ Regra", + "0: nothing is rejected.": "0: nada é rejeitado.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Um filtro está ocultando as fotos desta exibição: altere-o ou limpe-o (Exibir → Limpar filtros)", + "A one-time download of about": "Um download único de cerca de", + "About LightCraft": "Sobre o LightCraft", + "Activity": "Atividade", + "Actual size": "Tamanho real", + "Add": "Adicionar", + "Add Catchlight": "Adicionar brilho no olho", + "Add Folder to Local": "Adicionar pasta ao Local", + "Add Point Color Sample": "Adicionar amostra de cor por ponto", + "Add Red Eye Correction": "Adicionar correção de olhos vermelhos", + "Add Remove Spot": "Adicionar Remover manchas", + "Add Selected Photos": "Adicionar as fotos selecionadas", + "Add a rule (⌥-click: a group)": "Adicionar uma regra (⌥-clique: um grupo)", + "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 number": "Adicionar número", + "Add to": "Adicionar a", + "Add to Album": "Adicionar ao álbum", + "Add to Favorites": "Adicionar aos favoritos", + "Add to My Photos": "Adicionar às Minhas fotos", + "Add to Selected Photos": "Adicionar às fotos selecionadas", + "Add to Target Album": "Adicionar ao álbum de destino", + "Add to this mask": "Adicionar a esta máscara", + "Add/Subtract/Intersect Mask": "Adicionar/Subtrair/Interseção de máscara", + "Added to photos you import that don't already have it.": "Adicionado às fotos importadas que ainda não têm isso.", + "Adding": "Adicionando", + "Advanced search…": "Busca avançada…", + "After": "Depois", + "After 30 days": "Após 30 dias", + "After 7 days": "Após 7 dias", + "After 90 days": "Após 90 dias", + "After a year": "Após um ano", + "Album": "Álbum", + "Albums": "Álbuns", + "All": "Tudo", + "All Metadata": "Todos os metadados", + "All Metadata…": "Todos os metadados…", + "All Photos": "Todas as fotos", + "All rights reserved": "Todos os direitos reservados", + "All types": "Todos os tipos", + "Alt Text": "Texto alternativo", + "Always": "Sempre", + "Amount": "Quantidade", + "Aperture": "Abertura", + "Application": "Aplicativo", + "Apply Color Label Set": "Aplicar conjunto de rótulos de cor", + "Apply Filter Preset": "Aplicar predefinição de filtro", + "Apply Metadata Preset": "Aplicar predefinição de metadados", + "Apply Point Curve Preset": "Aplicar predefinição de curva de pontos", + "Apply Preset": "Aplicar predefinição", + "Apply Settings JSON": "Aplicar configurações JSON", + "Approved": "Aprovado", + "Aqua": "Ciano", + "Aqua Hue": "Matiz do ciano", + "Aqua Luminance": "Luminância do ciano", + "Aqua Saturation": "Saturação do ciano", + "ArtCraft Website": "Site do ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — mais aplicativos criativos", + "As Shot": "Como capturado", + "As shot": "Como capturado", + "Ascending": "Crescente", + "Aspect": "Proporção", + "Aspect Ratio": "Proporção de aspecto", + "Assisted Culling": "Seleção assistida", + "Assisted Culling…": "Seleção assistida…", + "Auto": "Automático", + "Auto Advance": "Avanço automático", + "Auto Advance Off": "Avanço automático desativado", + "Auto Advance On": "Avanço automático ativado", + "Auto Advance after rating or flagging": "Avanço automático após classificar ou marcar", + "Auto Advance: move to the next photo after rating or flagging": "Avanço automático: vai para a próxima foto após classificar ou marcar", + "Auto Align": "Alinhamento automático", + "Auto B&W Mix": "Mistura P&B automática", + "Auto Crop": "Recorte automático", + "Auto Import": "Importação automática", + "Auto Import Now": "Importar tudo agora", + "Auto Import Settings": "Configurações da importação automática", + "Auto Mask": "Máscara automática", + "Auto Settings": "Ajustes automáticos", + "Auto Straighten": "Endireitamento automático", + "Auto Sync": "Sincronização automática", + "Auto-Stack by Capture Time": "Empilhar automaticamente por horário de captura", + "Auto-Stack by Capture Time…": "Empilhar automaticamente por horário de captura…", + "Auto-Tag Photos from Tracklog": "Etiquetar fotos automaticamente pelo tracklog", + "Auto-Tag from Tracklog": "Etiquetar automaticamente pelo tracklog", + "Auto-Tag from Tracklog…": "Etiquetar automaticamente pelo tracklog…", + "Automatic": "Automático", + "Automatically Write Changes into XMP": "Gravar alterações automaticamente no XMP", + "Automatically write changes into XMP sidecars": "Grava automaticamente as alterações nos arquivos XMP auxiliares", + "Autumn Gold": "Ouro de outono", + "B&W": "P&B", + "B&W Mix": "Mistura P&B", + "B&W Mixer": "Misturador P&B", + "Back": "Voltar", + "Back Up Library…": "Fazer backup da biblioteca…", + "Back to Edit": "Voltar à edição", + "Back to Edit (E)": "Voltar à edição (E)", + "Back to Grid": "Voltar à grade", + "Background": "Fundo", + "Balance": "Balanço", + "Basic": "Básico", + "Before": "Antes", + "Before/After (Y)": "Antes/Depois (Y)", + "Before/After Settings": "Configurações de Antes/Depois", + "Before/After Split": "Divisão Antes/Depois", + "Before/After Split Top/Bottom": "Divisão Antes/Depois topo/base", + "Before/After Top/Bottom": "Antes/Depois topo/base", + "Begin Interaction": "Iniciar interação", + "Bit depth": "Profundidade de bits", + "Black & White": "Preto e branco", + "Blacks": "Pretos", + "Bleach Bypass": "Branqueamento omitido", + "Bleached": "Descolorido", + "Blending": "Mesclagem", + "Blue": "Azul", + "Blue Hour Boost": "Realce da hora azul", + "Blue Hue": "Matiz do azul", + "Blue Luminance": "Luminância do azul", + "Blue Primary": "Azul primário", + "Blue Saturation": "Saturação do azul", + "Blue channel": "Canal azul", + "Blue/Yellow Fringe": "Franja azul/amarela", + "Blurry photos below the score are flagged as rejects.": "Fotos desfocadas abaixo da pontuação são marcadas como rejeitadas.", + "Boundary Warp": "Deformação de borda", + "Bright & Airy": "Luminoso e arejado", + "Bring All to Front": "Trazer tudo para a frente", + "Browse Folder": "Navegar na pasta", + "Browse Folder…": "Navegar na pasta…", + "Browse Profiles": "Navegar pelos perfis", + "Browse…": "Navegar…", + "Brush": "Pincel", + "Brush Settings": "Configurações do pincel", + "Brush Stroke": "Traço do pincel", + "Build Previews": "Gerar prévias", + "Build Smart Previews": "Gerar prévias inteligentes", + "By Date": "Por data", + "By day (YYYY/YYYY-MM-DD)": "Por dia (AAAA/AAAA-MM-DD)", + "By month (YYYY/YYYY-MM)": "Por mês (AAAA/AAAA-MM)", + "Calibration": "Calibração", + "Camera": "Câmera", + "Cameras and Cards": "Câmeras e cartões", + "Cancel": "Cancelar", + "Cancel Download": "Cancelar download", + "Cancel Interaction": "Cancelar interação", + "Cancel Preview Build": "Cancelar geração de prévia", + "Candidate": "Candidato", + "Caption": "Legenda", + "Capture Date": "Data de captura", + "Captured": "Capturada", + "Catalog Statistics": "Estatísticas do catálogo", + "Check All": "Marcar todos", + "Check Output Path": "Verificar caminho de saída", + "Choose Another Library…": "Escolher outra biblioteca…", + "Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar", + "Choose Edit Settings to Copy…": "Escolher ajustes de edição a copiar…", + "Choose Folder…": "Escolher pasta…", + "Choose a photo in the grid or filmstrip": "Escolha uma foto na grade ou na película", + "Choose…": "Escolher…", + "Cinematic": "Cinematográfico", + "City": "Cidade", + "Clarity": "Clareza", + "Clean Studio": "Estúdio limpo", + "Clear": "Limpar", + "Clear Cache": "Limpar cache", + "Clear Filters": "Limpar filtros", + "Clear Guides": "Limpar guias", + "Clear History": "Limpar histórico", + "Clear Preview Cache": "Limpar cache de prévias", + "Clear Quick Collection": "Limpar coleção rápida", + "Clear Reference": "Limpar referência", + "Clear all": "Limpar tudo", + "Clear form": "Limpar formulário", + "Cleared History": "Histórico limpo", + "Click Zoom Ratio": "Proporção de zoom por clique", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Clique em uma tag para inseri-la. Os exemplos são de IMG_0042.CR3 tirada em 14 de jan. de 2026, 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Clique nas fotos da grade para alternar a palavra-chave; Esc encerra", + "Click the photo to pick a colour · ⇧-click adds more": "Clique na foto para escolher uma cor · ⇧-clique adiciona mais", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Clique na foto para escolher uma cor; ⇧-clique adiciona mais (até 5)", + "Clone": "Clonar", + "Close": "Fechar", + "Close Panel": "Fechar painel", + "Close Window": "Fechar janela", + "Close all": "Fechar tudo", + "Close file": "Fechar arquivo", + "Cloudy": "Nublado", + "Collapse All Stacks": "Recolher todas as pilhas", + "Color": "Cor", + "Color Grading": "Graduação de cores", + "Color Label Names": "Nomes dos rótulos de cor", + "Color Label Sets": "Conjuntos de rótulos de cor", + "Color Mixer": "Misturador de cores", + "Color Noise Reduction": "Redução de ruído de cor", + "Color Priority": "Prioridade de cor", + "Color Range": "Intervalo de cor", + "Color space": "Espaço de cor", + "Color space: sRGB (AVIF)": "Espaço de cor: sRGB (AVIF)", + "Combine files…": "Combinar arquivos…", + "Command Journal": "Diário de comandos", + "Comments panel": "Painel de comentários", + "Compare": "Comparar", + "Compare (Shift+C)": "Comparar (Shift+C)", + "Compare Before and After": "Comparar antes e depois", + "Compare needs at least two photos": "A comparação requer pelo menos duas fotos", + "Component options": "Opções do componente", + "Compression": "Compressão", + "Concrete Cool": "Concreto frio", + "Confirm before deleting": "Confirmar antes de excluir", + "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente", + "Constrain Crop": "Restringir recorte", + "Continue Without Saving": "Continuar sem salvar", + "Contrast": "Contraste", + "Convert to DNG": "Converter para DNG", + "Cool Fade": "Esmaecimento frio", + "Cool Morning": "Manhã fria", + "Copy": "Copiar", + "Copy After's Settings to Before": "Copiar os ajustes de Depois para Antes", + "Copy Before's Settings to After": "Copiar os ajustes de Antes para Depois", + "Copy Edit Settings": "Copiar ajustes de edição", + "Copy History Step Settings to Before": "Copiar os ajustes da etapa do histórico para Antes", + "Copy Metadata": "Copiar metadados", + "Copy as DNG": "Copiar como DNG", + "Copy into the library (else use the files where they are)": "Copiar para a biblioteca (caso contrário, use os arquivos onde estão)", + "Copy pages": "Copiar páginas", + "Copy the files (into the library's Originals/, or a folder you choose)": "Copiar os arquivos (para a pasta Originals/ da biblioteca ou para uma pasta de sua escolha)", + "Copy to": "Copiar para", + "Copy to Clipboard": "Copiar para a área de transferência", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Copiar: os arquivos são copiados para a pasta abaixo e a biblioteca usa as cópias; os originais ficam como estão.", + "Copyright": "Direitos autorais", + "Copyright Info URL": "URL das informações de direitos autorais", + "Copyright Status": "Status dos direitos autorais", + "Country": "País", + "Create Album": "Criar álbum", + "Create Album…": "Criar álbum…", + "Create Folder": "Criar pasta", + "Create Folder…": "Criar pasta…", + "Create New Mask": "Criar nova máscara", + "Create PDF from clipboard": "Criar PDF a partir da área de transferência", + "Create PDF from file…": "Criar PDF a partir de arquivo…", + "Create PDF from images…": "Criar PDF a partir de imagens…", + "Create Preset": "Criar predefinição", + "Create Preset…": "Criar predefinição…", + "Create Proof Copy": "Criar cópia de prova", + "Create Smart Album": "Criar álbum inteligente", + "Create Smart Album from Filter…": "Criar álbum inteligente a partir do filtro…", + "Create Smart Album…": "Criar álbum inteligente…", + "Create Stack": "Criar pilha", + "Create Version": "Criar versão", + "Create Version from Step": "Criar versão a partir da etapa", + "Create Virtual Copy": "Criar cópia virtual", + "Creator": "Criador", + "Crisp Landscape": "Paisagem nítida", + "Crisp Vista": "Vista nítida", + "Crop": "Recortar", + "Crop & Rotate": "Recortar e girar", + "Crop & Rotate (C)": "Recortar e girar (C)", + "Crop Aspect": "Proporção do recorte", + "Cross Process": "Processamento cruzado", + "Culling": "Triagem", + "Curve": "Curva", + "Custom": "Personalizado", + "Custom…": "Personalizado…", + "Cut": "Recortar", + "Cut pages": "Recortar páginas", + "Cycle Crop Overlay": "Alternar sobreposição de recorte", + "Cycle Crop Overlay Orientation": "Alternar orientação da sobreposição de recorte", + "Cycle Info Overlay": "Alternar sobreposição de informações", + "Cycle Mask Overlay Color": "Alternar cor da sobreposição da máscara", + "Cycle Mask Overlay Mode": "Alternar modo da sobreposição da máscara", + "Cylindrical": "Cilíndrico", + "Dark & Moody": "Escuro e sombrio", + "Darken": "Escurecer", + "Darks": "Escuros", + "Date Groups": "Grupos por data", + "Day": "Dia", + "Daylight": "Luz do dia", + "Decrease Brush Feather": "Diminuir esfumado do pincel", + "Decrease Brush Size": "Diminuir tamanho do pincel", + "Deep Sky": "Céu profundo", + "Defringe": "Remover franjas", + "Deghost Amount": "Quantidade de remoção de fantasmas", + "Dehaze": "Remover neblina", + "Delete": "Excluir", + "Delete (⌫)": "Excluir (⌫)", + "Delete Album": "Excluir álbum", + "Delete All Masks": "Excluir todas as máscaras", + "Delete Color Label Set": "Excluir conjunto de rótulos de cor", + "Delete Export Preset": "Excluir predefinição de exportação", + "Delete Filter Preset": "Excluir predefinição de filtro", + "Delete Imported Profile": "Excluir perfil importado", + "Delete Keyword": "Excluir palavra-chave", + "Delete Keyword Set": "Excluir conjunto de palavras-chave", + "Delete Mask": "Excluir máscara", + "Delete Metadata Preset": "Excluir predefinição de metadados", + "Delete Permanently": "Excluir permanentemente", + "Delete Photo": "Excluir foto", + "Delete Photos": "Excluir fotos", + "Delete Point Color Sample": "Excluir amostra de cor por ponto", + "Delete Point Curve Preset": "Excluir predefinição de curva de pontos", + "Delete Preset": "Excluir predefinição", + "Delete Red Eye Correction": "Excluir correção de olhos vermelhos", + "Delete Spot": "Excluir mancha", + "Delete Version": "Excluir versão", + "Delete all": "Excluir tudo", + "Delete all spots": "Excluir todas as manchas", + "Delete mask": "Excluir máscara", + "Delete them": "Excluí-las", + "Density": "Densidade", + "Depth Range": "Intervalo de profundidade", + "Descending": "Decrescente", + "Describe…": "Descrever…", + "Deselect": "Desmarcar", + "Deselect All": "Desmarcar tudo", + "Desert Heat": "Calor do deserto", + "Desert Warmth": "Calidez do deserto", + "Detail": "Detalhe", + "Detail (D)": "Detalhe (D)", + "Details": "Detalhes", + "Diagonal": "Diagonal", + "Discard Preview Cache": "Descartar cache de prévias", + "Display P3": "Display P3", + "Display theme": "Tema de exibição", + "Distortion": "Distorção", + "Document properties…": "Propriedades do documento…", + "Don't enlarge": "Não ampliar", + "Done": "Concluído", + "Drag along the horizon; double-click for Auto": "Arraste ao longo do horizonte; clique duplo para Automático", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Arraste sobre um olho na foto; a pupila dentro dele é encontrada automaticamente.", + "Draw Guides": "Desenhar guias", + "Duplicate": "Duplicar", + "Duplicate Mask": "Duplicar máscara", + "Duplicate and Invert Mask": "Duplicar e inverter máscara", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Cada / inicia um nível de pasta; as tags são preenchidas foto a foto (um nível sem metadados é \"desconhecido\").", + "Edit": "Editar", + "Edit (E)": "Editar (E)", + "Edit Capture Time": "Editar horário de captura", + "Edit Capture Time…": "Editar horário de captura…", + "Edit Color Label Names": "Editar nomes dos rótulos de cor", + "Edit Color Label Names…": "Editar nomes dos rótulos de cor…", + "Edit Copy for External Editor": "Editar cópia para o editor externo", + "Edit Info": "Editar informações", + "Edit Label Names…": "Editar nomes dos rótulos…", + "Edit Mask Component": "Editar componente da máscara", + "Edit Sections": "Editar seções", + "Edit Smart Album": "Editar álbum inteligente", + "Edit Smart Album…": "Editar álbum inteligente…", + "Edit Spot": "Editar mancha", + "Edit in External Editor": "Editar no editor externo", + "Edited": "Editada", + "Effect": "Efeito", + "Effects": "Efeitos", + "Enable Lens Corrections": "Ativar correções de lente", + "End Interaction": "Encerrar interação", + "English": "Inglês", + "Enter Full Screen": "Entrar em tela cheia", + "Erase": "Apagar", + "Every EXIF, GPS and XMP field in the file": "Todos os campos EXIF, GPS e XMP do arquivo", + "Existing previews": "Prévias existentes", + "Exit": "Sair", + "Expand All Stacks": "Expandir todas as pilhas", + "Expand/Collapse Stack": "Expandir/recolher pilha", + "Export": "Exportar", + "Export Album with Preset": "Exportar álbum com predefinição", + "Export Album…": "Exportar álbum…", + "Export As…": "Exportar como…", + "Export Group…": "Exportar grupo…", + "Export Now": "Exportar agora", + "Export Point Curve Presets": "Exportar predefinições de curva de pontos", + "Export Point Curve Presets…": "Exportar predefinições de curva de pontos…", + "Export Presets": "Exportar predefinições", + "Export Presets…": "Exportar predefinições…", + "Export User Presets…": "Exportar predefinições do usuário…", + "Export with Preset": "Exportar com predefinição", + "Export with Previous": "Exportar como antes", + "Exporting": "Exportando", + "Export…": "Exportar…", + "Exposure": "Exposição", + "Exposure (shutter · aperture · ISO)": "Exposição (tempo de exposição · abertura · ISO)", + "Extended Description": "Descrição estendida", + "External Editor": "Editor externo", + "Faded Matte": "Fosco desbotado", + "Faded Slide": "Slide desbotado", + "Faded Urban": "Urbano desbotado", + "Favorite Preset": "Predefinição favorita", + "Favorite Profile": "Perfil favorito", + "Favorites": "Favoritos", + "Feather": "Esfumado", + "File": "Arquivo", + "File & date": "Arquivo e data", + "File Name": "Nome do arquivo", + "File Path": "Caminho do arquivo", + "File Size": "Tamanho do arquivo", + "File name": "Nome do arquivo", + "Fill Edges": "Preencher bordas", + "Film": "Filme", + "Filmstrip": "Película", + "Filmstrip (/)": "Película (/)", + "Filter": "Filtro", + "Filter Bar": "Barra de filtros", + "Filter Presets": "Predefinições de filtro", + "Filter bar": "Barra de filtros", + "Filter fields": "Campos de filtro", + "Find Dust Spots": "Localizar manchas de poeira", + "Find Missing Photos": "Localizar fotos ausentes", + "Find Missing Photos…": "Localizar fotos ausentes…", + "Find Similar Photos": "Localizar fotos semelhantes", + "Find tools and commands…": "Localizar ferramentas e comandos…", + "Find…": "Localizar…", + "Fit": "Ajustar", + "Fit (F)": "Ajustar (F)", + "Fit on Screen": "Ajustar à tela", + "Fit to width": "Ajustar à largura", + "Fit visible": "Ajustar ao visível", + "Flag": "Marcar", + "Flag as Pick": "Marcar como seleção", + "Flag as Reject": "Marcar como rejeitada", + "Flash": "Flash", + "Flip H": "Espelhar H", + "Flip Horizontal": "Espelhar horizontalmente", + "Flip Overlay (⇧O)": "Inverter sobreposição (⇧O)", + "Flip V": "Espelhar V", + "Flip Vertical": "Espelhar verticalmente", + "Flow": "Fluxo", + "Fluorescent": "Fluorescente", + "Focal length": "Distância focal", + "Folder": "Pasta", + "Folder name (renamed on disk; its photos follow)": "Nome da pasta (renomeada no disco; as fotos acompanham)", + "Folders": "Pastas", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Pastas e {date} usam o horário de captura; uma foto sem esse dado usa a data de hoje.", + "Food": "Comida", + "Forget Unchanged Local Photos": "Esquecer fotos locais inalteradas", + "Forget unchanged photos": "Esquecer fotos inalteradas", + "Form fields panel": "Painel de campos de formulário", + "Format": "Formato", + "Forward": "Avançar", + "Free": "Livre", + "Fresh & Bright": "Fresco e brilhante", + "Friday": "Sexta-feira", + "Full": "Completa", + "Full Screen Preview": "Pré-visualização em tela cheia", + "Full screen mode": "Modo tela cheia", + "Full size": "Tamanho completo", + "GPS Track Log": "Registro de trajeto GPS", + "GPU Rendering": "Renderização por GPU", + "General": "Geral", + "Geometry": "Geometria", + "Get Develop Settings": "Obter ajustes de revelação", + "Global Hue": "Matiz global", + "Global Luminance": "Luminância global", + "Global Saturation": "Saturação global", + "Glossy": "Brilhante", + "Go to History Step": "Ir para etapa do histórico", + "Golden": "Dourado", + "Golden Glow": "Brilho dourado", + "Golden Hour": "Hora dourada", + "Grain": "Granulado", + "Graphic": "Gráfico", + "Green": "Verde", + "Green Amount": "Quantidade de verde", + "Green Hue": "Matiz do verde", + "Green Hue High": "Matiz do verde alta", + "Green Hue Low": "Matiz do verde baixa", + "Green Luminance": "Luminância do verde", + "Green Primary": "Primária verde", + "Green Saturation": "Saturação do verde", + "Green channel": "Canal verde", + "Grid": "Grade", + "Grid Info": "Informações da grade", + "Gritty Street": "Urbano cru", + "Group": "Grupo", + "Group by Date: Automatic": "Agrupar por data: automático", + "Group by Day": "Agrupar por dia", + "Group by Month": "Agrupar por mês", + "Group by Year": "Agrupar por ano", + "Group by date": "Agrupar por data", + "Group into Stack": "Agrupar em pilha", + "Guided": "Guiado", + "Guided Upright": "Vertical guiado", + "Guided Upright Guides": "Guias do vertical guiado", + "HDR Merge": "Mesclagem HDR", + "HDR Panorama Merge": "Mesclagem de panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR com os últimos ajustes", + "HDR Panorama…": "Panorama HDR…", + "HDR with Last Settings": "HDR com os últimos ajustes", + "HDR…": "HDR…", + "Heal": "Recuperar", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Recupera as pequenas manchas escuras e suaves que a poeira do sensor deixa em áreas lisas", + "Height": "Altura", + "Height (px)": "Altura (px)", + "Help": "Ajuda", + "Hide LightCraft": "Ocultar LightCraft", + "Hide Mask": "Ocultar máscara", + "Hide Others": "Ocultar outras", + "Hides this shortcut only; the folder and its photos stay as they are": "Oculta apenas este atalho; a pasta e suas fotos permanecem como estão", + "High": "Alto", + "High Contrast B&W": "P&B de alto contraste", + "Highlight": "Realce", + "Highlights": "Altas luzes", + "Highlights Hue": "Matiz das altas luzes", + "Highlights Luminance": "Luminância das altas luzes", + "Highlights Saturation": "Saturação das altas luzes", + "Highlights split": "Divisão de altas luzes", + "Histogram": "Histograma", + "History": "Histórico", + "History & Activity (Y)": "Histórico e atividade (Y)", + "Horizontal": "Horizontal", + "Horizontal text": "Texto horizontal", + "Hue": "Matiz", + "Hue Range": "Faixa de matiz", + "If file exists": "Se o arquivo existir", + "Image": "Imagem", + "Import": "Importar", + "Import Date": "Data de importação", + "Import Photos": "Importar fotos", + "Import Photos…": "Importar fotos…", + "Import Point Curve Presets": "Importar predefinições de curva de pontos", + "Import Point Curve Presets…": "Importar predefinições de curva de pontos…", + "Import Presets": "Importar predefinições", + "Import Presets…": "Importar predefinições…", + "Import Profiles": "Importar perfis", + "Import Profiles & Presets…": "Importar perfis e predefinições…", + "Import from Device": "Importar de dispositivo", + "Import from Folder…": "Importar de pasta…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importe fotos com Arquivo → Importar fotos… (Cmd+Shift+I) ou solte-as aqui", + "Imported Photos": "Fotos importadas", + "Importing": "Importando", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "A importação examina a origem em busca de fotos; ela não adiciona a pasta a Local (use Local → Procurar pasta… para trabalhar em uma pasta sem importar).", + "In Recently Deleted": "Em Recentemente excluídos", + "In library": "Na biblioteca", + "In memory — nothing is saved": "Na memória — nada é salvo", + "In use": "Em uso", + "In-memory session: these settings last until LightCraft quits.": "Sessão na memória: estes ajustes duram até o LightCraft ser encerrado.", + "Include subfolders": "Incluir subpastas", + "Increase Brush Feather": "Aumentar suavização do pincel", + "Increase Brush Size": "Aumentar tamanho do pincel", + "Info": "Informações", + "Info (I)": "Informações (I)", + "Info overlay": "Sobreposição de informações", + "Inspect Photo": "Inspecionar foto", + "Instant ’70s": "Instantâneo anos 70", + "Interface": "Interface", + "Interface language": "Idioma da interface", + "Intersect": "Interseccionar", + "Intersect with": "Interseccionar com", + "Into one folder": "Em uma única pasta", + "Invert": "Inverter", + "Invert Mask": "Inverter máscara", + "JPEG (Large)": "JPEG (grande)", + "JPEG (Small)": "JPEG (pequeno)", + "Join the ArtCraft Discord": "Entrar no Discord do ArtCraft", + "Join the ArtCraft Discord…": "Entrar no Discord do ArtCraft…", + "Keep in Local": "Manter em Local", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Guarde em um disco com espaço: as pré-visualizações inteligentes ocupam cerca de 1 MB por foto. O cache de miniaturas permanece na biblioteca.", + "Keyboard Shortcuts": "Atalhos de teclado", + "Keyboard shortcuts": "Atalhos de teclado", + "Keyword": "Palavra-chave", + "Keyword Painter": "Pincel de palavras-chave", + "Keyword Set": "Conjunto de palavras-chave", + "Keyword Sets": "Conjuntos de palavras-chave", + "Keyword Suggestions": "Sugestões de palavras-chave", + "Keyword to paint": "Palavra-chave a pintar", + "Keywords": "Palavras-chave", + "Keywords (K)": "Palavras-chave (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "As palavras-chave que você adiciona aparecem aqui; ⌥1–⌥9 as aplicam.", + "Landscape": "Paisagem", + "Language": "Idioma", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Maior aresta longa em que a exibição Detalhe é renderizada; maior deixa mais nítida em telas grandes, mas mais lenta.", + "Last": "Último", + "Last view": "Última exibição", + "Lateral Chromatic Aberration": "Aberração cromática lateral", + "Layer": "Camada", + "Layers": "Camadas", + "Leave them": "Deixá-los", + "Lens": "Lente", + "Lens Corrections": "Correções de lente", + "Level": "Nível", + "Library": "Biblioteca", + "Library Info": "Informações da biblioteca", + "Library Originals": "Originais da biblioteca", + "Library Preferences": "Preferências da biblioteca", + "Library State": "Estado da biblioteca", + "Licence:": "Licença:", + "Light": "Luz", + "LightCraft Default": "Padrão do LightCraft", + "LightCraft Help": "Ajuda do LightCraft", + "LightCraft Preset": "Predefinição do LightCraft", + "LightCraft Presets": "Predefinições do LightCraft", + "LightCraft Website": "Site do LightCraft", + "LightCraft couldn't find where to keep your library.": "O LightCraft não conseguiu encontrar onde guardar sua biblioteca.", + "LightCraft couldn't open the library at": "O LightCraft não conseguiu abrir a biblioteca em", + "LightCraft on GitHub": "LightCraft no GitHub", + "LightCraft website": "Site do LightCraft", + "LightCraft — Second Window": "LightCraft — segunda janela", + "Lights": "Luzes", + "Limit file size (KB, 0 = off)": "Limitar tamanho do arquivo (KB, 0 = desativado)", + "Linear Gradient": "Gradiente linear", + "List Albums": "Listar álbuns", + "List Develop Controls": "Listar controles de revelação", + "List History": "Listar histórico", + "List Presets": "Listar predefinições", + "List Profiles": "Listar perfis", + "List this folder in Local from now on": "Listar esta pasta em Local a partir de agora", + "Local": "Local", + "Local folders": "Pastas locais", + "Local library — no cloud account needed": "Biblioteca local — não precisa de conta na nuvem", + "Locate Missing File…": "Localizar arquivo ausente…", + "Locate Photo": "Localizar foto", + "Location": "Local", + "Long Edge": "Aresta longa", + "Looking for photos…": "Procurando fotos…", + "Lossless": "Sem perdas", + "Low": "Baixo", + "Luminance": "Luminância", + "Luminance Range": "Faixa de luminância", + "Lush Greens": "Verdes exuberantes", + "Magenta": "Magenta", + "Magenta Hue": "Matiz do magenta", + "Magenta Luminance": "Luminância do magenta", + "Magenta Saturation": "Saturação do magenta", + "Make Candidate the Select": "Tornar a candidata a selecionada", + "Manual": "Manual", + "Marquee zoom": "Zoom por seleção", + "Masking": "Máscara", + "Masking (M)": "Máscara (M)", + "Masks": "Máscaras", + "Match": "Igualar", + "Match Total Exposures": "Igualar exposições totais", + "Matte": "Fosco", + "Matte Soft": "Fosco suave", + "Med": "Médio", + "Medium Contrast": "Contraste médio", + "Megapixels": "Megapixels", + "Memory Budget": "Orçamento de memória", + "Memory Usage": "Uso de memória", + "Memory for caches": "Memória para caches", + "Menu": "Menu", + "Merge": "Mesclar", + "Merge Keywords": "Mesclar palavras-chave", + "Merge into…": "Mesclar em…", + "Merging…": "Mesclando…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Termos da Meta, não do LightCraft. Baixá-lo significa aceitá-los.", + "Metadata": "Metadados", + "Metadata Preset": "Predefinição de metadados", + "Metadata Presets": "Predefinições de metadados", + "Metadata preset": "Predefinição de metadados", + "Midpoint": "Ponto médio", + "Midtones": "Meios-tons", + "Midtones Hue": "Matiz dos meios-tons", + "Midtones Luminance": "Luminância dos meios-tons", + "Midtones Saturation": "Saturação dos meios-tons", + "Midtones split": "Divisão de meios-tons", + "Minimize": "Minimizar", + "Missing Photos": "Fotos ausentes", + "Misty Morning": "Manhã enevoada", + "Mode": "Modo", + "Modified Date": "Data de modificação", + "Monday": "Segunda-feira", + "Mono Red Filter": "Mono com filtro vermelho", + "Mono Rich": "Mono intenso", + "Mono Soft": "Mono suave", + "Monochrome": "Monocromático", + "Month": "Mês", + "Moody": "Atmosférico", + "Moody Portrait": "Retrato atmosférico", + "More": "Mais", + "More preset options": "Mais opções de predefinição", + "Move": "Mover", + "Move Album": "Mover álbum", + "Move Down in Stack": "Mover para baixo na pilha", + "Move Folder": "Mover pasta", + "Move Folder To…": "Mover pasta para…", + "Move Mask": "Mover máscara", + "Move Preset to Group": "Mover predefinição para grupo", + "Move Up in Stack": "Mover para cima na pilha", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Move os arquivos (para Originals/ da biblioteca ou uma pasta que você escolher), removendo-os da origem", + "Move them": "Movê-los", + "Move to": "Mover para", + "Move to Group": "Mover para grupo", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: os arquivos são movidos para a pasta abaixo; cada original (e seu sidecar XMP) é removido da origem somente após a cópia ser verificada. Duplicatas e arquivos que falham permanecem onde estão.", + "Muted": "Suave", + "Muted Film": "Filme suave", + "My Photos": "Minhas fotos", + "My Photos Panel": "Painel Minhas fotos", + "Name": "Nome", + "Named": "Nomeado", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Os nomes aparecem no menu de rótulos, na barra de filtros e no painel Informações, e são gravados no XMP. Vazio = o nome da cor.", + "Navigator": "Navegador", + "Needs Work": "Precisa de ajustes", + "Neon Dusk": "Crepúsculo neon", + "Neon Night": "Noite neon", + "Neutral": "Neutro", + "Never": "Nunca", + "New": "Novo", + "New Album": "Novo álbum", + "New Album…": "Novo álbum…", + "New Folder…": "Nova pasta…", + "New Group…": "Novo grupo…", + "New Smart Album": "Novo álbum inteligente", + "New Smart Album from Filter…": "Novo álbum inteligente a partir do filtro…", + "New Smart Album…": "Novo álbum inteligente…", + "New album": "Novo álbum", + "New album…": "Novo álbum…", + "New blank PDF": "Novo PDF em branco", + "New name": "Novo nome", + "New…": "Novo…", + "Next Photo": "Próxima foto", + "Night Blue": "Azul noturno", + "No": "Não", + "No Camera or Card Found": "Nenhuma câmera ou cartão encontrado", + "No Date Groups": "Sem grupos de datas", + "No Stars": "Sem estrelas", + "No Tool": "Sem ferramenta", + "No automatic versions.": "Sem versões automáticas.", + "No camera": "Sem câmera", + "No date": "Sem data", + "No dust spots found": "Nenhuma mancha de poeira encontrada", + "No edits yet.": "Ainda sem edições.", + "No exposure information": "Sem informações de exposição", + "No filter presets yet": "Ainda não há predefinições de filtro", + "No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).", + "No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.", + "No matching photos": "Nenhuma foto correspondente", + "No metadata found": "Nenhum metadado encontrado", + "No new presets": "Nenhuma predefinição nova", + "No photo selected": "Nenhuma foto selecionada", + "No photos": "Sem fotos", + "No photos found": "Nenhuma foto encontrada", + "No photos in this folder": "Nenhuma foto nesta pasta", + "No photos match the active filters": "Nenhuma foto corresponde aos filtros ativos", + "No photos match the filters (View → Clear Filters)": "Nenhuma foto corresponde aos filtros (Exibir → Limpar filtros)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Ainda não há fotos RAW: as câmeras aparecem aqui quando suas fotos estiverem na biblioteca.", + "No reference photo": "Nenhuma foto de referência", + "No usable GPU found: rendering on the CPU": "Nenhuma GPU utilizável encontrada: renderizando na CPU", + "No versions yet. Create one to keep this look.": "Ainda não há versões. Crie uma para manter este visual.", + "Noise": "Ruído", + "Noise Reduction": "Redução de ruído", + "Non-raw photos": "Fotos não RAW", + "None": "Nenhum", + "Nothing to compare": "Nada para comparar", + "Nothing to survey": "Nada para inspecionar", + "Nudge Develop Value": "Ajustar valor de revelação", + "OK": "OK", + "Object": "Objeto", + "Off": "Desativado", + "Offset X": "Deslocamento X", + "Offset Y": "Deslocamento Y", + "Opacity": "Opacidade", + "Opacity (%)": "Opacidade (%)", + "Open Library": "Abrir biblioteca", + "Open Library…": "Abrir biblioteca…", + "Open in": "Abrir em", + "Open in Detail": "Abrir em Detalhe", + "Open the place in OpenStreetMap": "Abrir o local no OpenStreetMap", + "Open…": "Abrir…", + "Optics": "Óptica", + "Optimize Library": "Otimizar biblioteca", + "Optional name": "Nome opcional", + "Options": "Opções", + "Orange": "Laranja", + "Orange Hue": "Matiz do laranja", + "Orange Luminance": "Luminância do laranja", + "Orange Saturation": "Saturação do laranja", + "Orientation": "Orientação", + "Original": "Original", + "Original + Settings": "Original + ajustes", + "Original files": "Arquivos originais", + "Original offline · editing the smart preview": "Original offline · editando a pré-visualização inteligente", + "Original offline · no smart preview": "Original offline · sem pré-visualização inteligente", + "Originals": "Originais", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Outras imagens (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Sobreposição", + "Overwrite": "Substituir", + "Pages": "Páginas", + "Paint": "Pintar", + "Paint only areas like the one under the brush": "Pinta apenas áreas como a que está sob o pincel", + "Paint over a distraction on the photo to remove it.": "Pinte sobre uma distração na foto para removê-la.", + "Paint overlay": "Sobreposição de pintura", + "Panorama Merge": "Mesclagem de panorama", + "Panorama with Last Settings": "Panorama com os últimos ajustes", + "Panorama…": "Panorama…", + "Parametric curve": "Curva paramétrica", + "Paste": "Colar", + "Paste Edit Settings": "Colar ajustes de edição", + "Paste Metadata": "Colar metadados", + "Paste Selected Settings": "Colar ajustes selecionados", + "Paste Selected Settings…": "Colar ajustes selecionados…", + "Paste Settings from Previous": "Colar ajustes da anterior", + "Paste pages": "Colar páginas", + "Pastel Haze": "Névoa pastel", + "Percent": "Porcentagem", + "Percentage": "Porcentagem", + "Performance": "Desempenho", + "Perspective": "Perspectiva", + "Pet Eye": "Olho de animal", + "Pet Eye Catchlight": "Brilho no olhar de animal", + "Photo": "Foto", + "Photo Grid": "Grade de fotos", + "Photo Grid (G)": "Grade de fotos (G)", + "Photo Merge": "Mesclagem de fotos", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Editar no editor externo (⇧⌘E) renderiza uma cópia TIFF de 16 bits, empilha-a com o original e a abre aqui (um nome de aplicativo no macOS, um caminho de programa em outros sistemas).", + "Photos": "Fotos", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotos vistas em Local mas nunca adicionadas nem alteradas saem do catálogo quando sua pasta não é navegada por esse período (verificado ao abrir a biblioteca). Os arquivos permanecem no disco; navegar pela pasta as mostra novamente.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotos que chegam a esta pasta (captura conectada, um scanner, um app de sincronização) são adicionadas assim que ficam completas.", + "Pick": "Escolher", + "Pick a colour on the photo with the eyedropper.": "Escolha uma cor na foto com o conta-gotas.", + "Pick the sharpest photo of each burst": "Escolher a foto mais nítida de cada sequência", + "Picked": "Escolhida", + "Picks": "Escolhidas", + "Pixels": "Pixels", + "Point Color": "Cor pontual", + "Point Curve": "Curva de pontos", + "Point Curve Presets": "Predefinições de curva de pontos", + "Point curve": "Curva de pontos", + "Portrait": "Retrato", + "Position": "Posição", + "Preferences": "Preferências", + "Preferences…": "Preferências…", + "Preset": "Predefinição", + "Preset name": "Nome da predefinição", + "Presets": "Predefinições", + "Presets & Profiles": "Predefinições e perfis", + "Presets (Shift+P)": "Predefinições (Shift+P)", + "Presets ▾": "Predefinições", + "Preview": "Pré-visualização", + "Preview Build Progress": "Progresso da geração de pré-visualizações", + "Preview only": "Somente pré-visualização", + "Preview size": "Tamanho da pré-visualização", + "Previews": "Pré-visualizações", + "Previous Photo": "Foto anterior", + "Print": "Imprimir", + "Print…": "Imprimir…", + "Profile": "Perfil", + "Profile Browser": "Navegador de perfis", + "Profile Distortion": "Distorção do perfil", + "Profile Menu": "Menu de perfis", + "Profile Vignetting": "Vinheta do perfil", + "Profile:": "Perfil:", + "Profiles": "Perfis", + "Projection": "Projeção", + "Properties": "Propriedades", + "Public domain": "Domínio público", + "Pupil Size": "Tamanho da pupila", + "Purple": "Roxo", + "Purple Amount": "Quantidade de roxo", + "Purple Hue": "Matiz do roxo", + "Purple Hue High": "Matiz do roxo alta", + "Purple Hue Low": "Matiz do roxo baixa", + "Purple Luminance": "Luminância do roxo", + "Purple Saturation": "Saturação do roxo", + "Put the locations you removed from Local back (no files change)": "Devolve os locais removidos de Local (nenhum arquivo muda)", + "Quality": "Qualidade", + "Query Photos": "Consultar fotos", + "Quick Collection": "Coleção rápida", + "Quick Develop": "Revelação rápida", + "Quiet Green": "Verde tranquilo", + "Quit": "Sair", + "Quit LightCraft": "Sair do LightCraft", + "Radial Gradient": "Gradiente radial", + "Radius": "Raio", + "Range": "Faixa", + "Rating": "Avaliação", + "Ratings & flags": "Avaliações e marcações", + "Raw default": "Padrão RAW", + "Raw defaults": "Padrões RAW", + "Raw files": "Arquivos RAW", + "Raw photos": "Fotos RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Fotos RAW como DNG, com as edições incorporadas. As opções de tamanho, cor e saída não se aplicam.", + "Read Metadata from File": "Ler metadados do arquivo", + "Read mode": "Modo de leitura", + "Read the SAM License": "Ler a licença do SAM", + "Reading": "Lendo", + "Reading folder…": "Lendo pasta…", + "Recently Added": "Adicionados recentemente", + "Recently Deleted": "Excluídos recentemente", + "Red": "Vermelho", + "Red Eye": "Olhos vermelhos", + "Red Hue": "Matiz do vermelho", + "Red Luminance": "Luminância do vermelho", + "Red Primary": "Primária vermelha", + "Red Saturation": "Saturação do vermelho", + "Red channel": "Canal vermelho", + "Red/Cyan Fringe": "Franja vermelha/ciano", + "Redo": "Refazer", + "Reference": "Referência", + "Reference View": "Exibição de referência", + "Reference the files where they are": "Referenciar os arquivos onde estão", + "Refine Mask Edges": "Refinar bordas da máscara", + "Refresh Source": "Atualizar origem", + "Refresh Source (/)": "Atualizar origem (/)", + "Reject": "Rejeitar", + "Reject below focus": "Rejeitar abaixo do foco", + "Rejected": "Rejeitada", + "Rejects": "Rejeitadas", + "Reload from Disk": "Recarregar do disco", + "Remove": "Remover", + "Remove (H)": "Remover (H)", + "Remove CA": "Remover AC", + "Remove Chromatic Aberration": "Remover aberração cromática", + "Remove from Album": "Remover do álbum", + "Remove from Favorites": "Remover dos favoritos", + "Remove from Local": "Remover de Local", + "Remove from Photo": "Remover da foto", + "Remove from Selected Photos": "Remover das fotos selecionadas", + "Remove from Stack": "Remover da pilha", + "Remove from survey": "Remover da inspeção", + "Remove location info": "Remover informações de local", + "Remove this filter": "Remover este filtro", + "Remove this group": "Remover este grupo", + "Remove this rule": "Remover esta regra", + "Rename": "Renomear", + "Rename Album": "Renomear álbum", + "Rename Folder": "Renomear pasta", + "Rename Folder…": "Renomear pasta…", + "Rename Keyword": "Renomear palavra-chave", + "Rename Keyword…": "Renomear palavra-chave…", + "Rename Mask": "Renomear máscara", + "Rename Photos": "Renomear fotos", + "Rename Photos…": "Renomear fotos…", + "Rename Preset": "Renomear predefinição", + "Rename Preview": "Renomear pré-visualização", + "Rename Template Tags": "Renomear marcadores do modelo", + "Rename Version": "Renomear versão", + "Rename…": "Renomear…", + "Rendering": "Renderizando", + "Rendering…": "Renderizando…", + "Reset": "Redefinir", + "Reset All Curves": "Redefinir todas as curvas", + "Reset Before to Import State": "Redefinir Antes para o estado de importação", + "Reset Control": "Redefinir controle", + "Reset Crop": "Redefinir recorte", + "Reset Edits": "Redefinir edições", + "Reset Parametric Curve": "Redefinir curva paramétrica", + "Reset Section": "Redefinir seção", + "Reset Tone Curve": "Redefinir curva de tonalidade", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Redefine todas as curvas: curvas de pontos (todos os canais) e paramétrica (clique duas vezes em um canal para redefinir apenas ele)", + "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", + "Resize to": "Redimensionar para", + "Resolution (ppi)": "Resolução (ppi)", + "Restore": "Restaurar", + "Restore Library from Backup…": "Restaurar biblioteca do backup…", + "Restore Version": "Restaurar versão", + "Retouch": "Retoque", + "Revert": "Reverter", + "Review Import": "Revisar importação", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clique com o botão direito em uma foto → Definir como foto de referência, depois Exibir → Exibição de referência (Shift+R)", + "Right-click a preset to delete it": "Clique com o botão direito em uma predefinição para excluí-la", + "Rights Usage Terms": "Termos de uso dos direitos", + "Rotate": "Girar", + "Rotate Crop Aspect": "Girar proporção do recorte", + "Rotate Left": "Girar à esquerda", + "Rotate Right": "Girar à direita", + "Roughness": "Aspereza", + "Roundness": "Arredondamento", + "SAM 3 download stopped: it resumes where it left off next time.": "O download do SAM 3 foi interrompido: ele continua de onde parou na próxima vez.", + "Same as raw default": "Igual ao padrão RAW", + "Sample Mask Color": "Amostrar cor da máscara", + "Sample a colour on the photo": "Amostrar uma cor na foto", + "Saturation": "Saturação", + "Saturation Range": "Faixa de saturação", + "Saturday": "Sábado", + "Save": "Salvar", + "Save As…": "Salvar como…", + "Save Color Label Set": "Salvar conjunto de rótulos de cor", + "Save Current Filter as Preset…": "Salvar filtro atual como predefinição…", + "Save Current Keywords as Set…": "Salvar palavras-chave atuais como conjunto…", + "Save Export Preset": "Salvar predefinição de exportação", + "Save Filter Preset": "Salvar predefinição de filtro", + "Save Keyword Set": "Salvar conjunto de palavras-chave", + "Save Metadata Preset": "Salvar predefinição de metadados", + "Save Metadata Preset…": "Salvar predefinição de metadados…", + "Save Metadata to File": "Salvar metadados no arquivo", + "Save Point Curve Preset": "Salvar predefinição de curva de pontos", + "Save Point Curve…": "Salvar curva de pontos…", + "Save as Preset": "Salvar como predefinição", + "Save as Smart Album…": "Salvar como álbum inteligente…", + "Save as set": "Salvar como conjunto", + "Save as…": "Salvar como…", + "Save preset": "Salvar predefinição", + "Scale": "Escala", + "Scanning": "Examinando", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "As pontuações ficam nas fotos: filtre ou crie álbuns inteligentes com Foco e Melhor de fotos semelhantes.", + "Screen": "Tela", + "Search Photos": "Pesquisar fotos", + "Search…": "Pesquisar…", + "Seasons": "Estações", + "Second Window": "Segunda janela", + "Select": "Selecionar", + "Select All": "Selecionar tudo", + "Select Luminance Range": "Selecionar faixa de luminância", + "Select Mask": "Selecionar máscara", + "Select Photos": "Selecionar fotos", + "Select Spot": "Selecionar ponto", + "Select a photo to edit": "Selecione uma foto para editar", + "Select by": "Selecionar por", + "Select by Flag, Rating or Label": "Selecionar por marcação, avaliação ou rótulo", + "Select photos, then choose View → Survey (N)": "Selecione fotos e escolha Exibir → Inspeção (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Selecione duas fotos e escolha Exibir → Comparar (Shift+C)", + "Selected": "Selecionada", + "Selenium Tone": "Tom de selênio", + "Send Feedback…": "Enviar comentários…", + "Sepia Tone": "Tom sépia", + "Services": "Serviços", + "Set Before": "Definir Antes", + "Set Color Label": "Definir rótulo de cor", + "Set Crop": "Definir recorte", + "Set Develop Values": "Definir valores de revelação", + "Set Flag": "Definir marcação", + "Set Mask Adjustments": "Definir ajustes da máscara", + "Set Point Curve": "Definir curva de pontos", + "Set Profile": "Definir perfil", + "Set Rating": "Definir avaliação", + "Set as Album Cover": "Definir como capa do álbum", + "Set as Before": "Definir como Antes", + "Set as Reference Photo": "Definir como foto de referência", + "Set as Target Album": "Definir como álbum de destino", + "Set as Target Album (B adds to it)": "Definir como álbum de destino (B adiciona a ele)", + "Set as Top of Stack": "Definir como topo da pilha", + "Set the mix from the photo's colours": "Define a mistura a partir das cores da foto", + "Settings": "Configurações", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Configurações das quais novas fotos RAW partem. Alterá-las não afeta fotos já na biblioteca.", + "Settings to include": "Configurações a incluir", + "Settings…": "Configurações…", + "Shade": "Sombra", + "Shadow": "Sombra", + "Shadows": "Sombras", + "Shadows Hue": "Matiz das sombras", + "Shadows Luminance": "Luminância das sombras", + "Shadows Saturation": "Saturação das sombras", + "Shadows Tint": "Tonalidade das sombras", + "Shadows split": "Divisão de sombras", + "Sharpen": "Nitidez", + "Sharpening": "Nitidez", + "Sharpness": "Nitidez", + "Short Edge": "Aresta curta", + "Show All": "Mostrar tudo", + "Show Clipping": "Mostrar recorte de tons", + "Show Deghost Overlay": "Mostrar sobreposição de remoção de fantasma", + "Show Hidden Local Locations": "Mostrar locais ocultos de Local", + "Show Luminance Map": "Mostrar mapa de luminância", + "Show Mask": "Mostrar máscara", + "Show Mask Overlay": "Mostrar sobreposição da máscara", + "Show Mask Pins": "Mostrar pinos da máscara", + "Show Original": "Mostrar original", + "Show Photo Counts": "Mostrar contagem de fotos", + "Show Photos with Keyword": "Mostrar fotos com a palavra-chave", + "Show Pins": "Mostrar pinos", + "Show Source": "Mostrar origem", + "Show Thumbnails": "Mostrar miniaturas", + "Show destination gamut warning": "Mostrar aviso de gamut de destino", + "Show display gamut warning": "Mostrar aviso de gamut da tela", + "Show file names": "Mostrar nomes dos arquivos", + "Show in Finder": "Mostrar no Finder", + "Show on Map": "Mostrar no mapa", + "Show overlay (O)": "Mostrar sobreposição (O)", + "Show ratings, flags and edit badges": "Mostrar avaliações, marcações e indicadores de edição", + "Show the Navigator while zoomed in": "Mostrar o Navegador quando ampliado", + "Show the template tags; click one to insert it": "Mostrar os marcadores do modelo; clique em um para inseri-lo", + "Show/Hide Mask": "Mostrar/ocultar máscara", + "Show/hide My Photos panel": "Mostrar/ocultar painel Minhas fotos", + "Shutter speed": "Velocidade do obturador", + "Side panels": "Painéis laterais", + "Sidecar names": "Nomes dos sidecars", + "Size": "Tamanho", + "Size (% of short edge)": "Tamanho (% da aresta curta)", + "Size limit": "Limite de tamanho", + "Skip": "Pular", + "Sky": "Céu", + "Slideshow": "Apresentação de slides", + "Smart Album": "Álbum inteligente", + "Smart Album Rule Fields": "Campos de regras do álbum inteligente", + "Smart Preview Status": "Status das pré-visualizações inteligentes", + "Smart Previews Location": "Local das pré-visualizações inteligentes", + "Smart previews": "Pré-visualizações inteligentes", + "Smoothness": "Suavidade", + "Soft B&W": "P&B suave", + "Soft Pastel": "Pastel suave", + "Soft Proofing": "Prova suave", + "Soft Skin": "Pele suave", + "Sort": "Classificar", + "Sort by": "Classificar por", + "Source": "Origem", + "Source code on GitHub": "Código-fonte no GitHub", + "Spherical": "Esférico", + "Spiral": "Espiral", + "Split Stack": "Dividir pilha", + "Spring Fresh": "Frescor da primavera", + "Square Grid": "Grade quadrada", + "Square Grid (G toggles)": "Grade quadrada (G alterna)", + "Square Grid: show file names and formats": "Grade quadrada: mostrar nomes e formatos dos arquivos", + "Stack": "Pilha", + "Stack photos taken within this time of each other:": "Empilhar fotos tiradas dentro deste intervalo:", + "Stacks": "Pilhas", + "Standard": "Padrão", + "Start at": "Começar em", + "Start number": "Número inicial", + "Startup": "Inicialização", + "State / Province": "Estado / província", + "Stop": "Parar", + "Stop Building Previews": "Parar geração de pré-visualizações", + "Stop Using as Target Album": "Parar de usar como álbum de destino", + "Stopping…": "Parando…", + "Straighten": "Endireitar", + "Straighten Tool": "Ferramenta Endireitar", + "Strong Contrast": "Contraste forte", + "Style": "Estilo", + "Subfolder": "Subpasta", + "Subfolder name": "Nome da subpasta", + "Subject": "Assunto", + "Subtract": "Subtrair", + "Subtract from this mask": "Subtrair desta máscara", + "Suggestions": "Sugestões", + "Summer Haze": "Névoa de verão", + "Sunday": "Domingo", + "Survey": "Inspeção", + "Survey (N)": "Inspeção (N)", + "Swap": "Trocar", + "Swap Aspect (X)": "Trocar proporção (X)", + "Swap Before and After Settings": "Trocar ajustes de Antes e Depois", + "Swap Compare Photos": "Trocar fotos da comparação", + "Switch light / dark theme": "Alternar tema claro / escuro", + "Sync Settings": "Sincronizar configurações", + "System Info": "Informações do sistema", + "System Info…": "Informações do sistema…", + "System default": "Padrão do sistema", + "Tags": "Marcadores", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Marcadores: consulte Marcadores ao lado do modelo. Os arquivos são renomeados no disco (com seus sidecars XMP); nomes existentes recebem -1, -2…", + "Take a snapshot": "Tirar um instantâneo", + "Targeted Adjustment": "Ajuste direcionado", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Ajuste direcionado: arraste para cima/baixo na foto para ajustar as cores ali", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Ajuste direcionado: arraste para cima/baixo na foto para ajustar a curva ali", + "Teal & Amber": "Verde-azulado e âmbar", + "Teal & Orange": "Verde-azulado e laranja", + "Temp": "Temp", + "Temperature": "Temperatura", + "Template": "Modelo", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sessão temporária — sua biblioteca não está aberta e nada do que você fizer aqui é salvo.", + "Text": "Texto", + "Text direction": "Direção do texto", + "Texture": "Textura", + "The SAM 3 model is installed: Object and Describe masks are ready.": "O modelo SAM 3 está instalado: as máscaras Objeto e Descrever estão prontas.", + "The download didn't work:": "O download não funcionou:", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Os arquivos originais, inalterados, cada um com um sidecar XMP contendo suas edições.", + "The photo has these settings": "A foto tem estas configurações", + "Theme": "Tema", + "They can be restored from Recently Deleted until it is emptied.": "Elas podem ser restauradas em Excluídos recentemente até que ele seja esvaziado.", + "Thirds": "Terços", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Esta pasta não está disponível (unidade desconectada?). As pré-visualizações inteligentes não são geradas nem usadas até ela voltar ou você escolher outra pasta.", + "This photo can't be opened": "Não foi possível abrir esta foto", + "Threshold": "Limite", + "Thumbnail cache": "Cache de miniaturas", + "Thursday": "Quinta-feira", + "Tint": "Tonalidade", + "Title": "Título", + "Toggle Keyword from Set": "Alternar palavra-chave do conjunto", + "Toggle Section": "Alternar seção", + "Toggle Zoom": "Alternar zoom", + "Tone Curve": "Curva de tonalidade", + "Tools": "Ferramentas", + "Top Level": "Nível superior", + "Transfer": "Transferir", + "Treatment": "Tratamento", + "Triangle": "Triângulo", + "Try Again": "Tentar novamente", + "Tuesday": "Terça-feira", + "Tungsten": "Tungstênio", + "Turn Off": "Desativar", + "Turn on Include subfolders, or pick another folder under Local": "Ative Incluir subpastas ou escolha outra pasta em Local", + "Type": "Tipo", + "Uncheck All": "Desmarcar tudo", + "Undo": "Desfazer", + "Unedited": "Não editada", + "Unflag": "Remover marcação", + "Unflagged": "Sem marcação", + "Ungroup Stack": "Desagrupar pilha", + "Unknown Date": "Data desconhecida", + "Unrated": "Sem avaliação", + "Unreadable": "Ilegível", + "Update": "Atualizar", + "Update Mask Shape": "Atualizar forma da máscara", + "Update Preset with Current Settings": "Atualizar predefinição com as configurações atuais", + "Update Rules from Current Filter": "Atualizar regras a partir do filtro atual", + "Update Version": "Atualizar versão", + "Update Version with Current Settings": "Atualizar versão com as configurações atuais", + "Update with Current Settings": "Atualizar com as configurações atuais", + "Upright": "Vertical", + "Urban": "Urbano", + "Use Keyword Set": "Usar conjunto de palavras-chave", + "Use Library Folder": "Usar pasta da biblioteca", + "Use camera-specific defaults": "Usar padrões específicos da câmera", + "Use the GPU for rendering": "Usar a GPU para renderização", + "User Presets": "Predefinições do usuário", + "Value": "Valor", + "Variance": "Variância", + "Version": "Versão", + "Version name": "Nome da versão", + "Versions": "Versões", + "Versions (Shift+V)": "Versões (Shift+V)", + "Vertical": "Vertical", + "Vertical text": "Texto vertical", + "Vibrance": "Intensidade", + "Videos": "Vídeos", + "View": "Exibir", + "Vignette": "Vinheta", + "Vignetting": "Vinheta", + "Vintage": "Vintage", + "Virtual Copy": "Cópia virtual", + "Visible": "Visível", + "Visualize Spots": "Visualizar manchas", + "Visualize Spots (A)": "Visualizar manchas (A)", + "Visualize range": "Faixa de visualização", + "Vivid": "Vívido", + "Vivid Pop": "Cores vívidas", + "Warm Film": "Filme quente", + "Warm Glow": "Brilho quente", + "Warm Print": "Impressão quente", + "Warm Table": "Mesa quente", + "Watched folder": "Pasta monitorada", + "Watermark": "Marca d'água", + "Wednesday": "Quarta-feira", + "What's New": "Novidades", + "When rated or hovered": "Ao avaliar ou passar o mouse", + "White Balance": "Balanço de branco", + "White Balance Selector": "Seletor de balanço de branco", + "White Balance Selector (W)": "Seletor de balanço de branco (W)", + "Whites": "Brancos", + "Width": "Largura", + "Width (% of photo)": "Largura (% da foto)", + "Width (px)": "Largura (px)", + "Width × Height": "Largura × altura", + "Window": "Janela", + "Winter Blue": "Azul invernal", + "XMP Sidecar Preferences": "Preferências de sidecar XMP", + "XMP sidecars": "Sidecars XMP", + "Year": "Ano", + "Yellow": "Amarelo", + "Yellow Hue": "Matiz do amarelo", + "Yellow Luminance": "Luminância do amarelo", + "Yellow Saturation": "Saturação do amarelo", + "Yes": "Sim", + "You can close this: the download continues, and resumes if interrupted.": "Você pode fechar isto: o download continua e retoma se for interrompido.", + "Your library couldn't be opened": "Não foi possível abrir sua biblioteca", + "Zoom": "Zoom", + "Zoom 100%": "Zoom 100%", + "Zoom In": "Ampliar", + "Zoom Out": "Reduzir", + "Zoom to Fit": "Ajustar à tela", + "Zoom to page level": "Zoom no nível da página", + "and": "e", + "comma, separated": "separado por vírgulas", + "memory only": "somente memória", + "none": "nenhum", + "not available here": "não disponível aqui", + "of the following rules:": "das seguintes regras:", + "saved in": "salvo em", + "this photo": "esta foto", + "− harder border · + softer border": "− borda mais dura · + borda mais suave" +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3fc90644..98763184 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -120,6 +120,7 @@ language_table! { ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); + PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -339,6 +340,10 @@ mod tests { assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); + assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); assert_eq!(Locale::parse_tag("de"), None); } @@ -353,6 +358,9 @@ mod tests { assert_eq!(tr("A string no catalog has"), "A string no catalog has"); set_language(Locale::Ja); assert_eq!(tr("Exposure"), "露出"); + set_language(Locale::PtBr); + assert_eq!(tr("Exposure"), "Exposição"); + assert_eq!(tr("Settings"), "Configurações"); set_language(Locale::En); assert_eq!(tr("Exposure"), "Exposure"); } @@ -457,6 +465,7 @@ mod tests { ("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja), + ("app.language.portuguese", Locale::PtBr), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -559,6 +568,8 @@ mod tests { assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); let text = painted_text(&ctx, &mut app, Locale::ZhHans); assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); + let text = painted_text(&ctx, &mut app, Locale::PtBr); + assert!(text.contains("Minhas fotos") && text.contains("Todas as fotos"), "{text}"); set_language(Locale::En); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5fc2b9bc..9e62a382 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -17,6 +17,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), + ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -33,6 +34,7 @@ pub fn language_from_command(id: &str) -> Option { "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), "app.language.japanese" => Some(crate::i18n::Locale::Ja), + "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), _ => None, } } diff --git a/docs/localization-pt-br.md b/docs/localization-pt-br.md new file mode 100644 index 00000000..72d5fcfe --- /dev/null +++ b/docs/localization-pt-br.md @@ -0,0 +1,36 @@ +# LightCraft em português do Brasil + +O LightCraft pode ser exibido em inglês, chinês simplificado, chinês tradicional (Taiwan), japonês e +português do Brasil. A preferência de idioma é gravada em `language` no `ui.json`. + +- Troque em **Editar → Idioma** ou em **Configurações → Geral → Idioma**. A escolha é mantida nas + próximas inicializações. +- Traduzimos menus, edição de fotos, máscaras, recorte, configurações, importação, exportação e as + principais mensagens de progresso. +- Menus, telas e o texto com valores variáveis usam as fontes Inter (regular e semibold). Texto em + chinês e japonês exibido nos nomes de idioma (por exemplo, 日本語) continua dependendo do repositório + de fontes [storytold/craft-fonts](https://github.com/storytold/craft-fonts). +- A tradução fica na camada de apresentação. IDs de comandos, nomes de arquivos de fotos e metadados + digitados pelo usuário nunca são alterados. +- Erros técnicos, informações complementares e notas de versão não traduzidos aparecem em inglês. +- A tradução para chinês está em [`localization-zh-hans.md`](localization-zh-hans.md); para japonês, + em [`localization-ja.md`](localization-ja.md). Como adicionar outro idioma está em + [`localization.md`](localization.md). + +## Manutenção da tradução + +Os textos fixos ficam em `crates/ui-egui/locales/pt-br.json` e os textos com valores variáveis em +`crates/ui-egui/locales/pt-br-formats.json`. O inglês é usado como chave. + +Toda tradução de formato é verificada por `format!` do Rust no build. Referencie os valores por nome +(`{n}`) ou por posição (`{0}`, `{1}`) e use as mesmas especificações de formato do inglês +(`{:.1}`, `{d:+}`, `{:.0}%`). Quando o plural inglês (`"s"` / `""`) não for necessário em português, +consuma o argumento correspondente com `{:.0}` — um texto com precisão 0 não imprime nada. + +Aparência, fontes, troca de idioma, persistência da preferência e estabilidade dos IDs de comando são +verificadas por `cargo test -p lightcraft-ui-egui i18n::tests` (a checagem de glifos só roda com +`CRAFT_FONTS_DIR`). Para visualizar o idioma, renderize sem interface gráfica: + +```sh +LIGHTCRAFT_LANGUAGE=pt-br lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000 +``` diff --git a/docs/localization.md b/docs/localization.md index f6db12ff..c0feb4f5 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,9 +2,9 @@ The interface ships in the language it is written in (English) plus every language in the table in `crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional -Chinese (`zh-hant`, Taiwan) and Japanese (`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes -are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md) and -[`localization-ja.md`](localization-ja.md). +Chinese (`zh-hant`, Taiwan), Japanese (`ja`) and Brazilian Portuguese (`pt-br`). This file is the reference for **adding or maintaining a language**; the per-language notes +are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md), +[`localization-ja.md`](localization-ja.md) and [`localization-pt-br.md`](localization-pt-br.md). ## Adding a language @@ -24,6 +24,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); + PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json"); } ``` diff --git a/docs/parity.md b/docs/parity.md index 82729971..09759ae0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -505,8 +505,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | -| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese and Brazilian Portuguese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index fc87ecc3..bad6a5b1 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -452,7 +452,7 @@ old fn reads_the_language_commands_from_menus_rs() { let menus = include_str!("../../crates/ui-egui/src/menus.rs"); let ids = ui_command_ids(menus); - for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese"] { + for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese", "app.language.portuguese"] { assert!(ids.iter().any(|known| known == id), "{id} not found"); } assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language"); From 6b0274120b641ee066a4f66b586edd2d8737b398 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 7 Oct 2026 16:08:01 -0600 Subject: [PATCH 14/19] Export: say so when there's no export folder instead of failing with Access denied On Windows HOME is usually unset, so the default export folder came out empty and photos were written by bare filename into the working directory, failing with 'Access is denied. (os error 5)'. - Default export folder uses %USERPROFILE%\Pictures\LightCraft Exports on Windows (matching the default library location). - A blank Folder in the Export dialog, or no folder resolvable at all, now fails with 'You need to specify an export location!' before any write. - The Export dialog stays open on an error (like Import) and shows it; the menu and keyboard export paths show it too instead of doing nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PNY4WojPYorWa6rQLxSYHa --- crates/ui-egui/src/control.rs | 32 ++++++++++++++++++++++------ crates/ui-egui/src/menubar.rs | 15 ++++++++++++- crates/ui-egui/src/panels/dialogs.rs | 5 +++-- crates/ui-egui/src/shortcuts.rs | 14 +++++++++--- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1b9d22bb..5eb30041 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -334,9 +334,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } } -/// Default export folder: `~/Pictures/LightCraft Exports` (falls back to the working directory). +/// Shown when an export has nowhere to go (no folder typed or chosen, and no home folder to default to). +pub const NO_EXPORT_FOLDER: &str = "You need to specify an export location!"; + +/// Default export folder: `~/Pictures/LightCraft Exports` (`%USERPROFILE%\Pictures\LightCraft Exports` +/// on Windows, where `HOME` usually isn't set). Empty when no home folder is known. pub fn default_export_dir() -> String { - std::env::var("HOME").map(|h| format!("{h}/Pictures/LightCraft Exports")).unwrap_or_default() + let home = if cfg!(windows) { std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) } else { std::env::var_os("HOME") }; + home.filter(|h| !h.is_empty()) + .map(|h| std::path::PathBuf::from(h).join("Pictures").join("LightCraft Exports").to_string_lossy().into_owned()) + .unwrap_or_default() } /// Export the selected photos (UI command `app.export`). Params: see @@ -365,10 +372,23 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result d.trim().to_string(), + None => { + let last_dir = + app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::trim).filter(|d| !d.is_empty()); + last_dir.map(str::to_string).unwrap_or_else(default_export_dir) + } + }; + if dir.is_empty() && exact.is_none() { + return Err(NO_EXPORT_FOLDER.into()); + } + let to = Destination { dir: dir.clone(), exact }; let background = p.get("background").and_then(Value::as_bool).unwrap_or(false) && app.services.write_shared.is_some(); let out = if background { let items = lightcraft_engine::export::prepare_batch(&mut app.session, &ids, &opts)?; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7e8054f2..0329415f 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -735,7 +735,13 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 }); } if let Some((id, params)) = clicked { - let _ = run_item(app, &id, params); + let r = run_item(app, &id, params); + // an export that can't start (e.g. no folder) says why instead of doing nothing + if let Err(e) = r + && matches!(id.as_str(), "app.export" | "app.exportPrevious") + { + app.toast(ui.ctx(), e); + } } ui.cursor().left() - start } @@ -945,6 +951,13 @@ mod tests { // remembered (expanded) for Export with Previous, folder included let last = app.session.last_export.clone().unwrap(); assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); + // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message + // instead of writing into the working directory + let n = w.len(); + drop(w); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); + assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); + assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); } #[test] diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index bc23383b..c2c3d267 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -824,8 +824,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if confirm { match confirm_dialog(app, &dlg) { - // the import review stays open on an error (e.g. an unusable folder template) - Err(e) if matches!(dlg, Dialog::Import { .. }) => app.toast(ctx, e), + // the import review stays open on an error (e.g. an unusable folder template), and so + // does Export (e.g. no folder chosen) so the choices aren't lost + Err(e) if matches!(dlg, Dialog::Import { .. } | Dialog::Export { .. }) => app.toast(ctx, e), // the SAM 3 dialog stays open to show the download (or why it can't start) Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { if let Dialog::SamModel { error, .. } = &mut dlg { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c5d2ce16..f76757e0 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -158,8 +158,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { o.remove("advance"); } cull(app, id, params, advance); - } else { - let _ = app.run(id, params); + } else if let Err(e) = app.run(id, params) + && matches!(id, "app.export" | "app.exportPrevious") + { + // an export that can't start (e.g. no folder) says why instead of doing nothing + app.toast(ctx, e); } } for f in fire { @@ -255,7 +258,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { continue; } } - let _ = app.run(&f, json!({})); + // an export that can't start (e.g. no folder) says why instead of doing nothing + if let Err(e) = app.run(&f, json!({})) + && matches!(f.as_str(), "app.export" | "app.exportPrevious") + { + app.toast(ctx, e); + } match f.as_str() { "photo.pick" => app.toast(ctx, "Flagged as Pick"), "photo.reject" => app.toast(ctx, "Flagged as Reject"), From 030ccf9782da8d7edeafddc5b59b74d5ff479741 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 18:11:13 -0400 Subject: [PATCH 15/19] Smart albums: File Path rule field Adds a "File Path" text rule so a smart album can match on where a photo lives, e.g. contains "/Aliah Ira Polanco-Grylls/". "contains" / "doesn't contain" match the whole string (spaces included) rather than word by word; the other text operators are the shared ones. `\` and `/` both separate and matching ignores case, so catalogs moved between platforms still match. Demo photos have no path. The rule editor, CLI and MCP pick the field up from FIELDS. parity.md's smart-collection rows now list it (and the stale field count is fixed). Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/rules.rs | 28 +++++++++++++++++++++++++++- docs/parity.md | 4 ++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 26e3926d..fab8566c 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{Catalog, Photo}; +use crate::{Catalog, Photo, Source}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -62,6 +62,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("keywords", "Keywords", Kind::Keywords), ("text", "Any Searchable Text", Kind::Text), ("fileName", "Filename", Kind::Text), + ("filePath", "File Path", Kind::Text), ("format", "File Format", Kind::Text), ("title", "Title", Kind::Text), ("caption", "Caption", Kind::Text), @@ -304,6 +305,20 @@ impl Rule { text(&all) } "fileName" => text(&p.file_name), + "filePath" => { + // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path + let path = match &p.source { + Source::File { path } => path.replace('\\', "/"), + Source::Demo { .. } => String::new(), + }; + let want = want.replace('\\', "/"); + match op { + // the whole string, spaces included (not word by word like the other text fields) + "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), + "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), + _ => text_op(op, &path, &want), + } + } "format" => text(&p.format), "title" => text(&m.title), "caption" => text(&m.caption), @@ -445,6 +460,17 @@ mod tests { ); no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); + // file path: the whole string (spaces included), either separator, case-insensitive + let mut fp = photo(3); + fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() }; + let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); + no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path + yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]})); diff --git a/docs/parity.md b/docs/parity.md index 82729971..6c60d490 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -717,8 +717,8 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning; no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | +| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | From 6aebd48eecfb5429a9bd128f55a528b8b4694fc6 Mon Sep 17 00:00:00 2001 From: Alex Gorevski Date: Wed, 7 Oct 2026 15:18:44 -0700 Subject: [PATCH 16/19] Docs: fix release and command references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73897efc-ca9e-48b7-8b5a-50be0a930b09 --- .github/workflows/release.yml | 5 ++-- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 10 +++---- docs/mcp.md | 4 +-- docs/releasing.md | 56 +++++++++++++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 docs/releasing.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cae4a17d..bbb2a0a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "LightCraft v". See docs/releasing.md. +# Release pipeline: every push to `release` builds platform artifacts (signed where credentials +# are configured) and creates or updates the draft GitHub Release "LightCraft v". +# See docs/releasing.md. # # All jobs run in the `release` environment (restricted to the `release` branch), which holds # the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a diff --git a/AGENTS.md b/AGENTS.md index c3072de8..588b6f41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # LightCraft — instructions for agents -LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions. +LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions. ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). diff --git a/CLAUDE.md b/CLAUDE.md index c3072de8..588b6f41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # LightCraft — instructions for agents -LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions. +LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions. ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). diff --git a/README.md b/README.md index 13ed80d7..6ff6bf3d 100644 --- a/README.md +++ b/README.md @@ -233,11 +233,11 @@ lightcraft --control 7980 ~/Pictures/trip {"method": "ui.screenshot", "params": {"path": "after.png"}} ``` -- **91 engine commands** and **48 UI commands.** List them all with `engine.commands`; read every slider's range, - default and current value with `develop.controls`. -- **MCP server.** `lightcraft-cli mcp` gives Claude (or any MCP client) ~100 tools: import, query, develop, mask, - render (returned as an image), export. It runs headless, or attached to the running app with screenshots, clicks - and gestures. See [docs/mcp.md](docs/mcp.md). +- **Command registry.** `engine.commands` lists the available commands; `develop.controls` lists every slider's + range, default and current value. +- **MCP server.** `lightcraft-cli mcp` exposes the command registry to Claude (or any MCP client), alongside + helpers for import, query, develop, mask, render (returned as an image) and export. It runs headless, or attached + to the running app with screenshots, clicks and gestures. See [docs/mcp.md](docs/mcp.md). ```sh cargo build --release -p lightcraft-cli diff --git a/docs/mcp.md b/docs/mcp.md index 826a4454..0e0e14b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -112,8 +112,8 @@ command registry (engine commands, plus the app's UI commands such as `view.deta connected): the id with `.` replaced by `_` and a `cmd_` prefix — `photo.rate` → `cmd_photo_rate`, `develop.set` → `cmd_develop_set`, `edit.undo` → `cmd_edit_undo`. Arguments are the command's JSON params (documented in each tool's description and by `list_commands`). Pass `--compact` to -leave these out (about 90 headless / 140 connected) when a client struggles with many tools; -`run_command` still reaches every command. +leave these per-command tools out when a client struggles with a large tool list; `run_command` +still reaches every command. ## Resources diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..b19d975d --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,56 @@ +# Releasing LightCraft + +The release pipeline is [`.github/workflows/release.yml`](../.github/workflows/release.yml). +It runs on every push to the `release` branch. A maintainer can also dispatch it manually, with +an optional version override such as `1.2.0-rc.1`; the release environment's branch restrictions +still apply. + +## Prepare and start a release + +1. Merge the release-ready changes into `release`. +2. Set the workspace version in `Cargo.toml` (for example, with `cargo xtask version set 1.2.0`). + For a manual workflow run, the version input can override the workspace version for that run. + Use a semantic version such as `1.2.0` or `1.2.0-rc.1`; versions containing a hyphen are + marked as prereleases. +3. Push to `release`, or dispatch the workflow from a branch allowed by the `release` environment. +4. Check the workflow run and its artifacts. On success, the workflow creates or updates a **draft** + GitHub Release named `LightCraft v`, targeted at the commit that triggered the run. + Review the draft and its `SHA256SUMS.txt`, then publish it in GitHub Releases when ready. + +The workflow replaces assets when it updates an existing draft. It stops rather than overwriting a +release that has already been published, so bump the version before producing another release. + +## Builds and artifacts + +Release jobs build macOS universal, Windows x86/x64/ARM64, Linux x86_64/aarch64, and the WASM web +app. The platform scripts in [`packaging/`](../packaging/) write their outputs to `dist/release/`: + +- macOS: app DMG and CLI ZIP. +- Windows: MSI installer and portable ZIP for each architecture. +- Linux: AppImage, `.deb`, `.rpm`, and `.tar.gz` for each architecture. +- Web: `lightcraft-web-.zip`. + +The Linux builds run on Ubuntu 22.04 and target glibc 2.35 or newer. AppImages and binaries may +also require system libraries for the windowing stack; the `.deb` and `.rpm` packages declare +their runtime dependencies. + +Release builds fetch the pinned [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts) +revision and require it (`CRAFT_FONTS_REQUIRED=1`). Keep that pin deliberate when updating the +workflow. + +## Signing credentials + +All credentials are stored as secrets in the GitHub `release` environment. Signing is optional: +without platform signing credentials, packaging continues with unsigned artifacts and warnings. +macOS notarization is a separate optional step; when its credentials are absent, a build with a +signing identity is signed but not notarized. Configure only the platform credentials you need: + +- **macOS signing:** `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, and `KEYCHAIN_PASSWORD`. + **Notarization** additionally needs all of `APPLE_ID`, `APPLE_PASSWORD` (an app-specific + password), and `APPLE_TEAM_ID`. +- **Windows signing:** either `WINDOWS_CERTIFICATE` and `WINDOWS_CERTIFICATE_PASSWORD`, or Azure + Trusted Signing credentials: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, + `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, and `AZURE_CERT_PROFILE`. + +Linux and web artifacts are not code-signed by this workflow. The release job computes SHA-256 +checksums for the complete artifact set and attaches the checksum file to the draft release. From 7f3d9ce25f2e0a4701dcb06649267b9cafde27d9 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 18:49:00 -0400 Subject: [PATCH 17/19] Sidebar: collapsible Albums, Local, By Date and Keywords sections Each header folds its section on click, with a chevron after the title showing the state. The choice is kept in the UI state across restarts. The Albums "+" button keeps working inside its header. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 55 ++++++++++++++++++++++-------- crates/ui-egui/src/state.rs | 14 ++++++++ crates/ui-egui/src/tests_panels.rs | 51 +++++++++++++++++++++++++++ docs/parity.md | 7 ++-- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 760eefde..e8d348eb 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -55,6 +55,31 @@ fn row( resp } +/// A collapsible section header (Albums, Local, By Date, Keywords): the bold title with a +/// disclosure chevron after it; a click folds or unfolds the section (kept in the UI state, so it +/// survives restarts). Returns the header's rect and whether the section is now open. +fn sidebar_section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> (Rect, bool) { + let t = Tokens::get(ui.ctx()); + let title = crate::i18n::tr(title); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); + register(ui.ctx(), format!("sidebarSection:{id}"), r); + if resp.clicked() { + app.ui.toggle_sidebar_section(id); + } + let open = !app.ui.sidebar_section_collapsed(id); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); + let text = ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); + let c = pos2(text.right() + 10.0, r.center().y); + let col = if resp.hovered() { t.text } else { t.text_dim }; + let pts = if open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); + (r, open) +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); @@ -85,8 +110,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.add_space(10.0); // Albums header - let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Albums"), t.semibold(13.5), t.text_label); + let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums"); let mut hdr = ui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())) @@ -111,14 +135,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); } }); - let albums: Vec = app.session.catalog.albums().cloned().collect(); - albums_tree(app, ui, &albums, None, 0.0); + if albums_open { + let albums: Vec = app.session.catalog.albums().cloned().collect(); + albums_tree(app, ui, &albums, None, 0.0); + } ui.add_space(10.0); local_section(app, ui); // By date - let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label); - for g in app.caches.date_groups(&app.session.catalog).iter() { + let (_, dates_open) = sidebar_section_header(app, ui, "byDate", "By Date"); + let groups = if dates_open { app.caches.date_groups(&app.session.catalog) } else { Default::default() }; + for g in groups.iter() { // year → month → day; a click filters by that prefix, the triangle opens a level if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { for (m, n) in &g.months { @@ -190,9 +216,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { if cfg!(target_arch = "wasm32") { return; } - let t = Tokens::get(ui.ctx()); - let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Local"), t.semibold(13.5), t.text_label); + let (_, open) = sidebar_section_header(app, ui, "local", "Local"); let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); let mut builtin: Vec<(String, String)> = Vec::new(); if !home.is_empty() { @@ -211,6 +235,10 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { if current.is_some() { app.ui.local_browse_root = local.browse_root.clone(); } + if !open { + ui.add_space(10.0); + return; + } for (i, (name, path)) in local.places.iter().enumerate() { let transient = local.browse_root.as_deref() == Some(path.as_str()); let reveal = local.owner == Some(i); @@ -666,15 +694,14 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); let tree = app.caches.keyword_tree(&app.session.catalog); if tree.is_empty() { return; } ui.add_space(10.0); - let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Keywords"), t.semibold(13.5), t.text_label); - keyword_rows(app, ui, &tree, 0.0); + if sidebar_section_header(app, ui, "keywords", "Keywords").1 { + keyword_rows(app, ui, &tree, 0.0); + } } fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index bdbbcf29..9d4df859 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -266,6 +266,9 @@ pub struct UiState { pub show_counts: bool, /// Face / pet boxes (read from XMP) over the photo in the loupe. pub face_boxes: bool, + /// Left-sidebar sections folded shut by their header (`albums`, `local`, `byDate`, + /// `keywords`); the rest are open. + pub collapsed_sidebar: Vec, /// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched). pub hidden_locations: Vec, /// Copies opened in an external editor this session (reloaded when the window is focused @@ -573,6 +576,7 @@ impl Default for UiState { grid_info: "filename".into(), show_counts: true, face_boxes: true, + collapsed_sidebar: Vec::new(), hidden_locations: Vec::new(), dragging_control: None, external_edits: Vec::new(), @@ -641,6 +645,16 @@ impl UiState { self.open_sections.push(id.to_string()); } } + pub fn sidebar_section_collapsed(&self, id: &str) -> bool { + self.collapsed_sidebar.iter().any(|s| s == id) + } + pub fn toggle_sidebar_section(&mut self, id: &str) { + if self.sidebar_section_collapsed(id) { + self.collapsed_sidebar.retain(|s| s != id); + } else { + self.collapsed_sidebar.push(id.to_string()); + } + } pub fn flyout_open(&self, id: &str) -> bool { self.open_flyouts.iter().any(|s| s == id) } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 7de05e0b..a289e2ee 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -148,3 +148,54 @@ fn a_narrow_window_shrinks_the_panels_without_forgetting_their_width() { h.settle(SETTLE); assert_eq!(widget(&h, "panel:right_panel").width(), 480.0); } + +/// The left sidebar's Albums, Local, By Date and Keywords headers fold their sections; the choice +/// is part of the saved UI state. +#[test] +fn sidebar_sections_collapse_and_remember_it() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let click = |h: &mut Headless, id: &str| { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + // By Date lists years while open; its header folds them away and back + assert!(has(&h, "sidebarSection:byDate")); + let year_rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:date:")).count(); + assert!(year_rows(&h) > 0, "the demo library has dated photos"); + click(&mut h, "sidebarSection:byDate"); + assert_eq!(year_rows(&h), 0, "folded"); + assert!(h.app.ui.sidebar_section_collapsed("byDate") && !h.app.ui.sidebar_section_collapsed("albums")); + assert!(has(&h, "sidebarSection:byDate"), "the header stays so it can be reopened"); + // the choice survives a save/load of the UI state + let saved = serde_json::to_value(&h.app.ui).unwrap(); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(back.sidebar_section_collapsed("byDate")); + click(&mut h, "sidebarSection:byDate"); + assert!(year_rows(&h) > 0, "unfolded again"); + // Albums folds too, and the plus button inside its header still works on its own + click(&mut h, "sidebarSection:albums"); + assert!(h.app.ui.sidebar_section_collapsed("albums")); + assert!(has(&h, "icon:albumNew"), "the Create Album button stays in the header"); + click(&mut h, "sidebarSection:albums"); + assert!(!h.app.ui.sidebar_section_collapsed("albums")); + // Keywords and Local fold their rows too + let rows = |h: &Headless, prefix: &str| h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).count(); + assert!(rows(&h, "source:keyword:") > 0, "the demo library has keywords"); + click(&mut h, "sidebarSection:keywords"); + assert_eq!(rows(&h, "source:keyword:"), 0, "keywords folded"); + click(&mut h, "sidebarSection:keywords"); + assert!(rows(&h, "source:keyword:") > 0); + if has(&h, "sidebarSection:local") { + click(&mut h, "sidebarSection:local"); + assert_eq!(rows(&h, "source:local:"), 0, "local folded"); + assert!(!has(&h, "source:local:browse"), "Browse Folder… folds with it"); + click(&mut h, "sidebarSection:local"); + assert!(!h.app.ui.sidebar_section_collapsed("local")); + } + // a click on the plus is the button's, not the header's + click(&mut h, "icon:albumNew"); + assert!(!h.app.ui.sidebar_section_collapsed("albums"), "the plus does not fold Albums"); +} diff --git a/docs/parity.md b/docs/parity.md index 82729971..e32f3d49 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | -| **Total** | 388 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.2% of 159. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. ## Top gaps @@ -501,6 +501,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | +| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | From ce165dfa4a5a80e52b107b6cb7b7990288b469f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 19:56:00 -0400 Subject: [PATCH 18/19] Export: calmer wording for the missing-folder error Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5eb30041..1a6abdde 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -335,7 +335,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } /// Shown when an export has nowhere to go (no folder typed or chosen, and no home folder to default to). -pub const NO_EXPORT_FOLDER: &str = "You need to specify an export location!"; +pub const NO_EXPORT_FOLDER: &str = "Choose an export folder first."; /// Default export folder: `~/Pictures/LightCraft Exports` (`%USERPROFILE%\Pictures\LightCraft Exports` /// on Windows, where `HOME` usually isn't set). Empty when no home folder is known. From d3c6dc0aa8b52ab3c2f6b81beaebd2903ce50b3b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 19:56:03 -0400 Subject: [PATCH 19/19] i18n zh-hans: "Add Remove Spot" means adding a spot removal Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/zh-hans.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0d94dcad..e29bb54f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -11,7 +11,7 @@ "Add Folder to Local": "将文件夹添加到本地", "Add Point Color Sample": "添加点颜色样本", "Add Red Eye Correction": "添加红眼校正", - "Add Remove Spot": "添加/移除污点", + "Add Remove Spot": "添加污点去除", "Add Selected Photos": "添加所选照片", "Add a rule (⌥-click: a group)": "添加条件(按 ⌥ 单击:添加组)", "Add in place": "在原位置添加",