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/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f9f8cef2..d6067b3f 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -1,7 +1,7 @@ # Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 # runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed # binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left -# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 on: diff --git a/AGENTS.md b/AGENTS.md index 393db2b1..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 `../printcraft` (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 393db2b1..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 `../printcraft` (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 9692b636..c6b98833 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. @@ -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 @@ -393,7 +393,7 @@ stand on its own. | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | -| | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | 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/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/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/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()))?; diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 84100638..a2ca64eb 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -224,6 +224,25 @@ fn select_by(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": n})) } +/// 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().filter(|n| *n < MAX_SEED).ok_or_else(|| bad(cmd, "seed must be an integer from 0 to 2^53 - 1")), + } +} + +/// 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) & (MAX_SEED - 1) +} + pub fn specs() -> Vec { vec![ // ---- view source / filter / sort @@ -281,7 +300,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 +311,31 @@ 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 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() + } + ), + cmd!( + "library.shuffle", + "Reshuffle", + ["View", "Sort"], + None, + "{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_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)()), + }; + s.sort = Sort { key: SortKey::Random, seed, ..s.sort }; ok() } ), 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/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 607b5e63..f9e0f492 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -466,3 +466,88 @@ 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!({"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(); + 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!(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_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(); + 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"); +} + +/// 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/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); +} 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/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/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7dc4d7c3..2090461a 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": "文件", @@ -287,19 +389,29 @@ "File Name": "文件名", "File Path": "文件路径", "File Size": "文件大小", + "Random": "随机", + "Reshuffle": "重新随机排序", "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 +428,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 +467,9 @@ "Green channel": "绿色通道", "Grid": "网格视图", "Grid Info": "网格信息", + "Gritty Street": "粗粝街景", "Group": "分组", + "Group by Date: Automatic": "按日期分组:自动", "Group by Day": "按日分组", "Group by Month": "按月分组", "Group by Year": "按年分组", @@ -352,9 +477,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 +494,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 +505,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 +531,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 +560,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 +568,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 +611,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 +649,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 +698,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 +736,7 @@ "None": "无", "Nothing to compare": "没有可比较的照片", "Nothing to survey": "没有可筛选的照片", + "Nudge Develop Value": "微调编辑数值", "OK": "确定", "Object": "对象", "Off": "关闭", @@ -563,13 +749,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 +768,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 +811,8 @@ "Point curve": "点曲线", "Portrait": "人像", "Position": "位置", + "Preferences": "首选项", + "Preferences…": "首选项…", "Preset": "预设", "Preset name": "预设名称", "Presets": "预设", @@ -620,16 +820,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 +847,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 +864,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 +881,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 +906,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 +934,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 +1051,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 +1097,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 +1189,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 +1221,8 @@ "Width (px)": "宽度 (px)", "Width × Height": "宽度 × 高度", "Window": "窗口", + "Winter Blue": "冬日蓝调", + "XMP Sidecar Preferences": "XMP 附属文件偏好设置", "XMP sidecars": "XMP 附属文件", "Year": "年", "Yellow": "黄色", @@ -938,94 +1230,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/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/control.rs b/crates/ui-egui/src/control.rs index 1b9d22bb..1a6abdde 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 = "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. 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/headless.rs b/crates/ui-egui/src/headless.rs index 599e1d8a..f6dc8a9b 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()) @@ -385,7 +399,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"); @@ -421,7 +436,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); @@ -532,12 +547,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); 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/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/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/menubar.rs b/crates/ui-egui/src/menubar.rs index 7e8054f2..8012a26b 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", @@ -458,13 +459,30 @@ 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, + (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 = [ @@ -735,7 +753,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 } @@ -787,6 +811,42 @@ 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, .. }))); + // 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 /// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. #[test] @@ -945,6 +1005,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/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/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index e36cba6a..f75feca4 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.key != Random && cur.ascending, crate::i18n::tr("Ascending")).clicked() { + let _ = app.run("library.sort", json!({"ascending": true})); + } + if ui.selectable_label(cur.key != Random && !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/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/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/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"), 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_masking.rs b/crates/ui-egui/src/tests_masking.rs index bdb972c0..ea6589cd 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -373,16 +373,9 @@ fn local_folder_tree_expands_and_browses() { let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T); assert_eq!(r["ok"], true, "{r}"); let trip = base.join("Trip").to_string_lossy().to_string(); - // the subfolders are listed on a worker thread: wait for the row, longer on a busy machine - let listed = std::time::Instant::now(); - while listed.elapsed() < Duration::from_secs(20) { - h.step(); - let w = h.request("ui.widgets", json!({"filter": format!("source:local:{trip}")}), T); - if w["result"].as_array().is_some_and(|a| !a.is_empty()) { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } + // 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"); 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/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); +} 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-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)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框 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/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/parity.md b/docs/parity.md index 82729971..90c5703c 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 @@ -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; 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) @@ -501,12 +501,13 @@ 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` | | | 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 | ⬜ | | | @@ -570,7 +571,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 | ⬜ | | | @@ -696,7 +697,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 | @@ -717,8 +718,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 | 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. 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");