Merge main into #243 (keep main's settle and step_until in the two flaky UI tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9da8163ae2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# LightCraft — instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../drawcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
|
|
|
|||
12
README.md
12
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 <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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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}]}));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,6 +287,8 @@
|
|||
"File Name": "ファイル名",
|
||||
"File Path": "ファイルの場所",
|
||||
"File Size": "ファイルサイズ",
|
||||
"Random": "ランダム",
|
||||
"Reshuffle": "シャッフルし直す",
|
||||
"File name": "ファイル名",
|
||||
"Fill Edges": "境界を補完",
|
||||
"Filmstrip": "フィルムストリップ",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -389,6 +389,8 @@
|
|||
"File Name": "檔案名稱",
|
||||
"File Path": "檔案路徑",
|
||||
"File Size": "檔案大小",
|
||||
"Random": "隨機",
|
||||
"Reshuffle": "重新隨機排序",
|
||||
"File name": "檔案名稱",
|
||||
"Fill Edges": "填補邊緣",
|
||||
"Film": "底片",
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -206,6 +206,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())
|
||||
|
|
@ -389,7 +403,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");
|
||||
|
|
@ -425,7 +440,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);
|
||||
|
|
@ -536,8 +551,8 @@ 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);
|
||||
// Local's rows (Downloads, Home, …) appear when their background checks land and push
|
||||
// the keyword rows down: clicking before that hit the row above (`travel|france`)
|
||||
// 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}");
|
||||
|
|
@ -545,6 +560,7 @@ mod tests {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -691,15 +719,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) {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,7 +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();
|
||||
assert!(h.wait_for_widget(&format!("source:local:{trip}"), T), "the asynchronous subfolder listing did not arrive");
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue