Merge main into #207 (keep main's step_until wait for the folder row)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-07 20:11:00 -04:00
commit 46f5d212e7
41 changed files with 2454 additions and 168 deletions

View File

@ -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<version>". 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<version>".
# 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

View File

@ -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:

View File

@ -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).

View File

@ -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).

View File

@ -200,7 +200,7 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. 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.
<table>
@ -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.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |

View File

@ -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};

View File

@ -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() }

View File

@ -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}]}));

View File

@ -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<PhotoId> {
(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<PhotoId> = 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::<Sort>(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());
}

View File

@ -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<String, String> {
fn craft_fonts(dir: &Path) -> Result<String, String> {
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()))?;

View File

@ -224,6 +224,25 @@ fn select_by(s: &mut Session, p: &Value) -> Result<Value> {
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<u64> {
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<CommandSpec> {
vec![
// ---- view source / filter / sort
@ -281,7 +300,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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()
}
),

View File

@ -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();

View File

@ -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<u64> = 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);
}

View File

@ -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);
}

View File

@ -287,6 +287,8 @@
"File Name": "ファイル名",
"File Path": "ファイルの場所",
"File Size": "ファイルサイズ",
"Random": "ランダム",
"Reshuffle": "シャッフルし直す",
"File name": "ファイル名",
"Fill Edges": "境界を補完",
"Filmstrip": "フィルムストリップ",

View File

@ -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}"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -389,6 +389,8 @@
"File Name": "檔案名稱",
"File Path": "檔案路徑",
"File Size": "檔案大小",
"Random": "隨機",
"Reshuffle": "重新隨機排序",
"File name": "檔案名稱",
"Fill Edges": "填補邊緣",
"Film": "底片",

View File

@ -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<Value, String
if ids.is_empty() {
return Err("no photo selected".into());
}
// no folder given (e.g. File → Export with Preset): the last export's, else the default
let last_dir = app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::to_string);
let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).or(last_dir).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir);
let to = Destination { dir: dir.clone(), exact: p.get("path").and_then(Value::as_str).map(str::to_string) };
// An exact output file needs no folder. A folder given but left blank (the Export dialog's Folder
// field cleared) is an error, like Lightroom refusing to export to an unspecified folder, rather
// than a silent write into the working directory. No folder at all (e.g. File → Export with
// Preset): the last export's, else the default.
let exact = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string);
let dir = match p.get("dir").and_then(Value::as_str) {
Some(d) => 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)?;

View File

@ -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);

View File

@ -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);
}

View File

@ -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");
}
}
}
}

View File

@ -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};

View File

@ -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<Vec<MenuNode>> {
("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<MenuNode>)]) -> Vec<MenuNode> {
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::<Vec<_>>()
};
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]

View File

@ -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<crate::i18n::Locale> {
"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,
}
}

View File

@ -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 [

View File

@ -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 {

View File

@ -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<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
if albums_open {
let albums: Vec<Album> = 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) {

View File

@ -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"),

View File

@ -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<String>,
/// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched).
pub hidden_locations: Vec<String>,
/// 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)
}

View File

@ -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");

View File

@ -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");
}

View File

@ -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);
}

View File

@ -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
```

View File

@ -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)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框

View File

@ -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");
}
```

View File

@ -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

View File

@ -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.
<!-- /parity:summary -->
## 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:<albums\|local\|byDate\|keywords>`) 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 |

56
docs/releasing.md Normal file
View File

@ -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<version>`, 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-<version>.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.

View File

@ -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");