diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..9e9702005 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,69 @@ + + +Fixes # + + + +## Problem + + + +## Change + + + +**Not in this PR:** + +## How it was checked + +- [ ] `cargo xtask ci` passes on this branch, rebased on current `main` +- [ ] A test that fails without this change (for a crash fix: a regression test that panicked before it) +- [ ] User-visible change: ran the app and looked at the result + + + +## Screenshots + + + +## Merging + + + +- **Overlap:** +- **Existing edits and libraries:** +- **Shared numbers taken:** +- **Where it came from:** +- **New dependencies, `unsafe`, build scripts, network access, spawned processes, deleted files, CI or `xtask` gate + changes:** + +## Checklist + +- [ ] No new panic path in non-test code: no `unwrap` / `expect` / `panic!`, no indexing or unchecked arithmetic on a + number from a file, a person or an agent (`get`, `checked_mul`, NaN and char boundaries handled) +- [ ] New text on screen goes through `tr()` and has its key in `crates/ui-egui/locales/*.json`; a new shortcut is + free in `docs/library-shortcuts.md` +- [ ] The feature's rows in `docs/parity-checklist.md` and its entry in `docs/gaps.md` are updated, honestly (🟡 when + it is partial), in the same commit +- [ ] New code has a caller: no command, module or setting that nothing reaches yet +- [ ] Every new image, icon or other asset has its entry in `assets/ATTRIBUTION.md`; no font or media files added +- [ ] Ready to merge (otherwise open it as a draft) diff --git a/CLAUDE.md b/CLAUDE.md index d2d538d65..416ffc0a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` `Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row. - **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match. - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). +- **Quality gates** before every commit: `cargo xtask ci --quick` (fmt, clippy -D warnings, the changed packages' + tests, layers, assets, and heif / parity / wasm when touched). GitHub runs the full `cargo xtask ci` on every PR + (`.github/workflows/ci.yml`); run the full one locally before merging branches (below) or when `--quick` can't see + the risk. One `ci` runs at a time per machine; others wait their turn (`xtask/src/ci_lock.rs`). - **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment. ## Assets: icons, images, fonts (ABSOLUTE RULE — never violate) diff --git a/Cargo.lock b/Cargo.lock index 8b3985fc0..78de058c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3356,7 +3356,7 @@ version = "0.13.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", - "quick-xml 0.38.4", + "quick-xml", ] [[package]] @@ -3394,7 +3394,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-tiff", "proptest", - "quick-xml 0.38.4", + "quick-xml", "serde", "serde_json", ] @@ -3403,6 +3403,7 @@ dependencies = [ name = "lightcraft-pipeline" version = "0.13.0" dependencies = [ + "dhat", "image", "lightcraft-color", "lightcraft-develop", @@ -4848,15 +4849,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -6347,7 +6339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index 22956f31b..c22395b8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"] version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] @@ -60,7 +60,7 @@ libc = "0.2" # dev-only: process CPU time in benches dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds # formats -quick-xml = "0.38" +quick-xml = "0.41" miniz_oxide = "0.8" # codecs diff --git a/README.md b/README.md index dd7c538f7..78b3b3234 100644 --- a/README.md +++ b/README.md @@ -405,8 +405,17 @@ cargo run --release -p lightcraft -- --control 7980 # with the automation ch cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM +cargo xtask ci --quick # only what your branch's changes can break (minutes) ``` +`cargo xtask ci --quick` compares with the merge base of `origin/main` (`--base REF` for another) +and runs fmt, clippy, the tests of the changed packages, layers and assets, plus HEIF, parity and +the wasm check when the changes touch them; a change to `Cargo.toml`, `Cargo.lock` or the lint +config tests everything. GitHub runs the full `cargo xtask ci` on every pull request +(`.github/workflows/ci.yml`). Only one `ci` runs at a time per machine: a second one, from another +worktree say, waits for the first and names it, since several at once take longer in total than in +turn. `LIGHTCRAFT_CI_SLOTS=2` allows two; `LIGHTCRAFT_CI_LOCK=off` skips the lock. + CI defaults to line-table debug information and one build job per 1.5 GB of RAM available when it starts (at most one per CPU; 4 if memory can't be read), so an 8 GB machine with 6 GB free builds with 4, and a busy machine with little free diff --git a/README.zh-hans.md b/README.zh-hans.md index bc2f2830e..2800db410 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -298,10 +298,10 @@ lightcraft --control 7980 ~/Pictures/trip ## Feature status -LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)): +LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md)): - **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在 - [docs/parity.md](docs/parity.md)。 + [docs/parity-checklist.md](docs/parity-checklist.md)。 - **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** @@ -309,7 +309,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** -- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#where-were-going)。 +- **接下来做什么:** 见[我们要去往哪里](ROADMAP.md#upcoming)。 | 领域 | 状态 | |---|---| @@ -333,7 +333,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( | 镜头校正(畸变、暗角、自动 + 手动色差、去边、DNG 文件内嵌的镜头校正以及松下/徕卡 RW2/RWL 畸变数据)、几何(变换、限制裁剪)、垂直校正(自动/水平/垂直/完整/引导式) | ✅ · 相机镜头配置文件(我们自己的)⬜ | | 照片合并:HDR(自动对齐、去重影)、全景(球面/圆柱/透视、边界变形、自动裁剪)、HDR 全景 → DNG | ✅ | | GPU 管线(wgpu 计算,与 CPU 一致到 1/255 以内)、设备限制/出错时回退到 CPU | ✅ · 浏览器中的 WebGPU 🚧 | -| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#where-were-going)) | +| AI:分割蒙版、AI 降噪、超分辨率、人脸;视频 | ⬜(见[路线图](ROADMAP.md#upcoming)) | | 网页版(通过 WASM 在浏览器中使用同一界面):图库持久化在 OPFS/IndexedDB、Web Worker 渲染、导出下载 | ✅ · WebGPU、Safari/Firefox 测试 🚧 | ✅ 现已可用 · 🚧 进行中 · ⬜ 尚未开始 diff --git a/ROADMAP.md b/ROADMAP.md index 6d8720b6e..6802f9234 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -114,6 +114,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-22: Print page setup and PDF output gain the Japanese photo and postcard papers — L判 + (89 × 127 mm), 2L判 (127 × 178), KG判 (102 × 152), はがき (100 × 148), 往復はがき (148 × 200), + 六切 (203 × 254), 四切 (254 × 305) and 名刺 (55 × 91) — at their exact metric sizes. The Print dialog + and `print.set` take their customary names, and names are matched case-, space- and + punctuation-insensitively (`L判`, `l`, `L-size` are one paper). - 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277. @@ -186,6 +191,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged | | 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present | | 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | diff --git a/apps/lightcraft-web/src/automation.rs b/apps/lightcraft-web/src/automation.rs new file mode 100644 index 000000000..ff11e0ee9 --- /dev/null +++ b/apps/lightcraft-web/src/automation.rs @@ -0,0 +1,48 @@ +//! The page's automation entry point, `lightcraft.command(id, params)` (see `web.rs`): what the +//! params text means. Plain Rust, so it is tested natively. + +use serde_json::Value; + +/// The command parameters in `text`: a JSON object, or `{}` when the text is empty or blank (or +/// `null`). Anything else is an error, never `{}`: a command run without its params acts on the +/// selection or with defaults, so a typo such as `{ids: [3]}` must not delete or reset the +/// selected photos. The control channel and MCP refuse such params the same way. +pub fn params(text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(Value::Object(Default::default())); + } + match serde_json::from_str::(text) { + Ok(Value::Null) => Ok(Value::Object(Default::default())), + Ok(v @ Value::Object(_)) => Ok(v), + Ok(_) => Err("params must be a JSON object, e.g. {\"ids\": [3]}".into()), + Err(e) => Err(format!("params are not valid JSON ({e}); nothing was run")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn blank_or_null_params_are_an_empty_object() { + for text in ["", " ", "\n", "null", " null "] { + assert_eq!(params(text), Ok(json!({})), "{text:?}"); + } + } + + #[test] + fn an_object_is_passed_through() { + assert_eq!(params(r#"{"ids": [3], "rating": 5}"#), Ok(json!({"ids": [3], "rating": 5}))); + } + + /// Before, any of these ran the command with `{}`: `photo.delete` then removed the selection. + #[test] + fn invalid_json_or_a_non_object_is_refused() { + for text in ["{ids: [3]}", "{\"ids\": [3]", "ids=3", "[3]", "3", "\"{}\"", "true"] { + assert!(params(text).is_err(), "{text:?} must not run the command"); + } + assert!(params("{ids: [3]}").unwrap_err().contains("nothing was run")); + } +} diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index 58966e6e8..f984cb34c 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod automation; pub mod backup; pub mod bench; pub mod files; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 14a5d050b..827b367f8 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -23,7 +23,11 @@ use crate::store::{Originals, content_hash, download_name, storage_key}; use crate::wire::ThumbIndex; use crate::workers::{THUMB_INDEX, Workers}; -const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp"; +/// The web picker's accept list, derived from the engine's import extensions (both +/// cases) so no format the engine can import is silently filtered out of the dialog. +fn accept_list() -> String { + lightcraft_engine::import::accept_list() +} /// After a failed save, wait this long before trying again (ms). const SAVE_RETRY_MS: f64 = 2000.0; @@ -123,7 +127,7 @@ fn open_picker(originals: Originals, backend: Option, ctx: egui::Contex let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; input.set_type("file"); input.set_multiple(true); - input.set_accept(ACCEPT); + input.set_accept(&accept_list()); let inp = input.clone(); let on_change = Closure::::new(move || { if let Some(files) = inp.files() { @@ -273,6 +277,8 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc, first_frame_logged: bool, + /// The page title last set (`document.title`), so it is written only when it changes. + title: String, last_save: f64, ui_written: Vec, } @@ -501,6 +509,7 @@ impl WebApp { lib_dir, bench: opts.bench.then(|| Bench::new(origin)), first_frame_logged: false, + title: String::new(), last_save: 0.0, ui_written, } @@ -508,22 +517,37 @@ impl WebApp { fn import_dropped(&mut self, ctx: &egui::Context) { let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + // a dialog is on screen (the card said so while the files hovered): nothing is read or imported + // (files read from an earlier drop still finish below) + if !dropped.is_empty() && self.app.ui.dialog.is_some() { + self.app.toast(ctx, lightcraft_ui_egui::i18n::tr("Close the open dialog, then drop the files again")); + return; + } for f in dropped { let (originals, backend, ctx) = (self.originals.clone(), self.backend.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { let name = f.path().to_string_lossy().to_string(); match f.bytes_async().await { Ok(bytes) => store_file(originals, backend, name, bytes, ctx).await, - Err(e) => log::warn!("reading dropped {name}: {e}"), + Err(e) => notice(lightcraft_ui_egui::i18n::dropped_file_unreadable(&name, &e.to_string())), } }); } let paths = self.originals.take_pending(); if !paths.is_empty() { - let n = paths.len(); match self.app.run("library.import", json!({"paths": paths})) { - Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })), - Err(e) => log::warn!("import: {e}"), + // what the import did, not what was submitted: duplicates and unsupported files add nothing, + // a drop that only finds duplicates says nothing (as the desktop import's toast does when it + // shows where they are), and one whose files could not be read says so + Ok(report) => { + if let Some(notice) = lightcraft_ui_egui::i18n::dropped_photos_outcome(&report) { + self.app.toast(ctx, notice); + } + } + Err(e) => { + log::warn!("import: {e}"); + self.app.toast(ctx, lightcraft_ui_egui::i18n::dropped_import_failed(&e)); + } } } } @@ -639,8 +663,11 @@ impl WebApp { fn run_inbox(&mut self) { let inbox = INBOX.with(|q| std::mem::take(&mut *q.borrow_mut())); for (id, params, (resolve, reject)) in inbox { - let params: serde_json::Value = serde_json::from_str(¶ms).unwrap_or(json!({})); - let r = if id == "web.stats" { Ok(self.web_stats()) } else { self.app.run(&id, params) }; + let r = match crate::automation::params(¶ms) { + Err(e) => Err(format!("{id}: {e}")), + Ok(_) if id == "web.stats" => Ok(self.web_stats()), + Ok(params) => self.app.run(&id, params), + }; let _ = match r { Ok(v) => resolve.call1(&JsValue::NULL, &v.to_string().into()), Err(e) => reject.call1(&JsValue::NULL, &e.into()), @@ -686,6 +713,14 @@ impl eframe::App for WebApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.app.ui(ui); + // eframe's web runner ignores `ViewportCommand::Title`: name the open photo in the page title + let title = self.app.page_title(self.files.is_dirty() || self.flushing.get()); + if title != self.title + && let Some(doc) = window().and_then(|w| w.document()) + { + doc.set_title(&title); + self.title = title; + } if !self.first_frame_logged && !self.app.widgets.is_empty() { self.first_frame_logged = true; log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now()); diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 08f9e898d..53d389b6f 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -522,7 +522,7 @@ mod tests { } }; frame(&mut watch, &mut index); // an empty filtered view: no render request yet - cache.clear(); // File ▸ Clear Preview Cache + cache.clear(); // File ▸ Discard Preview Cache frame(&mut watch, &mut index); // the filter is removed: the first thumbnail request (`Workers::try_start`) gone.extend(watch.observe(&cache, cache.generation(), &mut index)); diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index c7244c703..95aa10896 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -247,6 +247,9 @@ mod tests { let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); let (stream, _) = listener.accept().unwrap(); + // Small test messages must not wait for Nagle's algorithm or a delayed ACK. + client.set_nodelay(true).unwrap(); + stream.set_nodelay(true).unwrap(); before(&mut client); let (tx, requests) = channel(); let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap(); @@ -316,6 +319,10 @@ mod tests { // A dispatched command waits for its reply, not for socket input. std::thread::sleep(Duration::from_millis(250)); req.reply.send(json!({"ok": true, "result": id})).unwrap(); + if id == 2 { + // The worker must reach EOF even before the client reads its final reply. + connection.join(); + } let mut reply = String::new(); connection.client.read_line(&mut reply).unwrap(); let reply: Value = serde_json::from_str(&reply).unwrap(); diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index cf6d66079..3ca748f47 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -31,22 +31,14 @@ pub fn check(app: &str) -> Result<(), String> { /// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside. pub fn open_with(path: &str, app: &str) -> Result<(), String> { let mut c = match launcher(app)? { - Launcher::Default if cfg!(target_os = "macos") => { - let mut c = Command::new("open"); - c.arg(path); - c - } + Launcher::Default if cfg!(target_os = "macos") => return run_open(&[path]), Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path), Launcher::Default => { let mut c = Command::new("xdg-open"); c.arg(path); c } - Launcher::MacApp(app) => { - let mut c = Command::new("open"); - c.args(["-a", &app, path]); - c - } + Launcher::MacApp(app) => return run_open(&["-a", &app, path]), Launcher::Program(p) => { let mut c = Command::new(p); c.arg(path); @@ -61,6 +53,22 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> { c.spawn().map(|_| ()).map_err(|e| e.to_string()) } +/// macOS: run `open` and wait for it (it returns as soon as LaunchServices has handed the file +/// over), so an application it can't find or start is reported instead of silently doing nothing. +fn run_open(args: &[&str]) -> Result<(), String> { + let out = Command::new("open").args(args).output().map_err(|e| e.to_string())?; + open_result(out.status.success(), &String::from_utf8_lossy(&out.stderr)) +} + +/// What `open` said, as a result: its own message when it failed. +fn open_result(ok: bool, stderr: &str) -> Result<(), String> { + if ok { + return Ok(()); + } + let msg = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("the application couldn't be started"); + Err(msg.to_string()) +} + /// Open every file in `paths` with one start of `app`, as a file manager hands over a selection: /// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is /// still starting when it is started once per file. The system default opens them one by one @@ -119,10 +127,15 @@ fn launcher(app: &str) -> Result { fn find_photocraft() -> Option { let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string()); if cfg!(target_os = "macos") { - // LaunchServices finds the app wherever it is; these are only the usual places + // the usual places, then wherever Spotlight knows it (by bundle id or bundle name) let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app")); - let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); - return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string()))); + let usual = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); + let spotlight = || { + let query = format!("kMDItemCFBundleIdentifier == '{PHOTOCRAFT_ID}' || kMDItemFSName == 'PhotoCraft.app'"); + let out = Command::new("mdfind").arg(query).output().ok().filter(|o| o.status.success())?; + mdfind_app(&String::from_utf8_lossy(&out.stdout)) + }; + return usual.or_else(spotlight).map(|p| Launcher::MacApp(p.to_string_lossy().to_string())); } if cfg!(target_os = "windows") { let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir")); @@ -130,16 +143,34 @@ fn find_photocraft() -> Option { return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program); } // Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which - // registers one on first run), else the Flatpak - let on_path = - env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).map(|d| d.join("photocraft")).find(|p| p.is_file()); + // registers one on first run; its Exec a path or a name on the PATH), an AppImage where people + // keep them, else the Flatpak + let path_dirs: Vec = env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).collect(); + let on_path = |name: &Path| resolve_program(name, &path_dirs); let from_entry = || { desktop_entry_dirs(env).into_iter().find_map(|d| { let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?; - desktop_exec(&text).filter(|p| p.is_file()) + desktop_exec(&text) + // a wrapper (`Exec=env VAR=1 /opt/PhotoCraft/photocraft %F`) would be started with the photo only: + // skip it and look further + .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().to_lowercase().contains("photocraft"))) + .and_then(|p| on_path(&p)) }) }; - if let Some(p) = on_path.or_else(from_entry) { + let appimage = || { + let home = env("HOME")?; + let dirs = [Path::new(&home).join("Applications"), Path::new(&home).join(".local/bin")]; + // both folders, so the newest version wins whichever it is in + let mut found: Vec = dirs + .iter() + .filter_map(|d| std::fs::read_dir(d).ok()) + .flat_map(|rd| rd.flatten().map(|e| e.path()).filter(|p| is_photocraft_appimage(p) && p.is_file()).collect::>()) + .collect(); + // the newest version by name (1.10 after 1.9) + found.sort_by_key(|p| version_key(&p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default())); + found.pop() + }; + if let Some(p) = on_path(Path::new("photocraft")).or_else(from_entry).or_else(appimage) { return Some(Launcher::Program(p)); } let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success()); @@ -205,10 +236,44 @@ fn desktop_exec(entry: &str) -> Option { Some(rest) => rest.split('"').next()?, None => exec.split_whitespace().next()?, }; - // a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a - // freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on - // (`Path::is_absolute` wants a drive letter on Windows, where the tests run too) - program.starts_with('/').then(|| PathBuf::from(program)) + // a path, or a bare name (`photocraft %F`) the caller looks for on the PATH + (!program.is_empty()).then(|| PathBuf::from(program)) +} + +/// A program from a desktop entry or the PATH: a path as it is (if it is a file), a bare name +/// looked for in `dirs`. A leading `/` counts as absolute on every platform (desktop entries are +/// a POSIX format; `Path::is_absolute` wants a drive letter on Windows, where the tests run too). +fn resolve_program(name: &Path, dirs: &[PathBuf]) -> Option { + if name.is_absolute() || name.starts_with("/") { + return Some(name.to_path_buf()).filter(|p| p.is_file()); + } + dirs.iter().map(|d| d.join(name)).find(|p| p.is_file()) +} + +/// A sort key that orders the digit runs in a name by value (`1.9` before `1.10`). +fn version_key(name: &str) -> Vec<(u64, String)> { + let mut out = Vec::new(); + let mut rest = name; + while !rest.is_empty() { + let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + let (num, tail) = rest.split_at(digits); + let text_len = tail.find(|c: char| c.is_ascii_digit()).unwrap_or(tail.len()); + let (text, next) = tail.split_at(text_len); + out.push((num.parse().unwrap_or(u64::MAX), text.to_string())); + rest = next; + } + out +} + +/// An AppImage of PhotoCraft by its file name (`PhotoCraft-1.2-x86_64.AppImage`, any case). +fn is_photocraft_appimage(p: &Path) -> bool { + let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + name.contains("photocraft") && name.ends_with(".appimage") +} + +/// The first application bundle in `mdfind` output (one path per line). +fn mdfind_app(output: &str) -> Option { + output.lines().map(str::trim).find(|l| l.ends_with(".app")).map(PathBuf::from) } #[cfg(test)] @@ -275,13 +340,59 @@ mod tests { let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n"; assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage"))); assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft"))); - assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup"); + assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), Some(PathBuf::from("photocraft")), "a bare name, for a PATH lookup"); assert_eq!(desktop_exec("[Desktop Entry]\n"), None); let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string())); assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications")); assert!(dirs.contains(&PathBuf::from("/usr/share/applications"))); } + #[test] + fn a_bare_exec_name_is_found_on_the_path() { + let a = std::env::temp_dir().join(format!("lc-editors-a-{}", std::process::id())); + let b = std::env::temp_dir().join(format!("lc-editors-b-{}", std::process::id())); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(b.join("photocraft"), b"").unwrap(); + let dirs = vec![a.clone(), b.clone()]; + assert_eq!(resolve_program(Path::new("photocraft"), &dirs), Some(b.join("photocraft"))); + assert_eq!(resolve_program(Path::new("nothere"), &dirs), None); + let abs = b.join("photocraft"); + assert_eq!(resolve_program(&abs, &[]), Some(abs.clone()), "a path needs no PATH"); + assert_eq!(resolve_program(&b.join("missing"), &dirs), None); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn appimage_versions_sort_numerically() { + let mut names = ["photocraft-1.10-x86_64.appimage", "photocraft-1.9-x86_64.appimage", "photocraft-1.2-x86_64.appimage"]; + names.sort_by_key(|n| version_key(n)); + assert_eq!(names[2], "photocraft-1.10-x86_64.appimage"); + assert_eq!(names[0], "photocraft-1.2-x86_64.appimage"); + } + + #[test] + fn a_failed_open_is_an_error() { + assert!(open_result(true, "").is_ok()); + assert!(open_result(false, "Unable to find application\n").is_err()); + assert!(open_result(false, "").is_err(), "even when it says nothing"); + } + + #[test] + fn odd_detection_input_does_not_panic() { + for text in ["", "Exec=", "Exec=\"", "Exec=\"\"", "\u{0}\u{fffd}", "Exec= %F", "[Desktop Entry]\nExec=\u{e9}\u{301}"] { + let _ = desktop_exec(text); + let _ = mdfind_app(text); + let _ = reg_value(text); + } + for p in ["", "/", ".AppImage", "\u{e9}.appimage", "a/../b"] { + let _ = is_photocraft_appimage(Path::new(p)); + } + let _ = desktop_entry_dirs(|_| None); + let _ = windows_candidates(None, Some("\"".into()), |_| None); + } + #[test] fn other_editors_are_used_as_named() { assert_eq!(launcher("").unwrap(), Launcher::Default); diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 95739b3e9..7d30e69cd 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -3,8 +3,10 @@ //! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control ] [files or folders…]` //! //! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else -//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else -//! `~/Pictures/LightCraft Library`; a new library starts with the +//! `$LIGHTCRAFT_LIBRARY`, else — with Settings ▸ General ▸ At launch set to *Ask me*, or Alt/Option +//! held as the window appears — the one chosen in a list of the recent libraries, else the library +//! last opened, else `~/Pictures/LightCraft Library` +//! ([`lightcraft_ui_egui::panels::library_problem::launch_plan`]); a new library starts with the //! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command //! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes //! nothing (demo photos unless files are given; used by the README showcase scripts). A library @@ -39,7 +41,7 @@ mod window_gpu; use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; -use lightcraft_ui_egui::panels::library_problem::LibraryProblem; +use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; /// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name. @@ -363,7 +365,7 @@ struct PrefsWriter { impl PrefsWriter { fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - self.save_ui(&app.ui, app.library_problem.is_some()) + self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) } /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be @@ -718,9 +720,13 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> } /// The library session with the folders face models (`/models`) and slideshow templates -/// (`/slideshow-templates`) are kept in. -fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { - let (mut s, problem) = open_library_session(in_memory, dir, seed_demo); +/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: +/// the window lists the recent ones. +fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { + let (mut s, problem) = match launch { + Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), + Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), + }; s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); // Control requests are answered on the UI thread: Add to Apple Photos must not wait there @@ -769,7 +775,9 @@ lightcraft — photo library + raw developer USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) OPTIONS: - --library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY) + --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or + a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, + else ~/Pictures/LightCraft Library) --no-demo don't seed a new library with the procedural demo photos --memory throwaway in-memory session (alias --demo); nothing is saved --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; @@ -900,15 +908,15 @@ fn main() -> eframe::Result { if let Some(ui) = &prefs { lightcraft_ui_egui::i18n::set_language(ui.language); } - // --library, else the library last opened from Settings, else the default location - let library_dir = library_dir.or_else(|| { - prefs - .as_ref() - .map(|u| u.settings.library_path.clone()) - .filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none()) - .map(Into::into) - }); - let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); + // --library, else LIGHTCRAFT_LIBRARY, else the list of recent libraries if Settings say Ask + // me, else the library last opened from Settings, else the default location + let launch = lightcraft_ui_egui::panels::library_problem::launch_plan( + in_memory, + library_dir, + std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into), + prefs.as_ref().map(|u| &u.settings), + lightcraft_engine::library::default_dir(), + ); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory)); @@ -961,7 +969,7 @@ fn main() -> eframe::Result { // the window's renderer is up (eframe calls this after creating it) win_files_app.started(window_renderer); came_up_app.store(true, std::sync::atomic::Ordering::Relaxed); - let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); + let (mut session, mut problem) = open_session(in_memory, &launch, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) @@ -972,6 +980,11 @@ fn main() -> eframe::Result { if let Some(ui) = prefs { app.ui = ui; } + // the recent libraries to choose from (issue #1290), checked on a worker once shown + if launch == Launch::Ask { + problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + } + app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. }); lightcraft_ui_egui::i18n::set_language(app.ui.language); app.integrated_titlebar = cfg!(target_os = "macos"); app.notices.extend(prefs_warning); @@ -1145,12 +1158,40 @@ mod tests { /// adds to Apple Photos on a worker (headless sessions wait instead). #[test] fn the_desktop_session_adds_to_apple_photos_in_the_background() { - let (s, problem) = open_session(true, None, false); + let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); assert!(problem.is_none()); assert!(s.apple_photos_background); assert!(!lightcraft_engine::Session::new().apple_photos_background); } + /// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo + /// photos) before the user chooses, and the settings are still saved while the list shows (a + /// removed entry is kept), unlike the failed-library window's temporary session. + #[test] + fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { + let (s, problem) = open_session(false, &Launch::Ask, true); + assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); + assert!(s.apple_photos_background, "the same desktop session as an opened one"); + + let d = dir("writer-ask"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(s, Services::default()); + app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + app.ui.settings.recent_libraries.remove(0); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + // the failed-library window still writes nothing + app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); + app.ui.settings.recent_libraries.clear(); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + let _ = std::fs::remove_dir_all(&d); + } + /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and /// reported, not silently replaced by the defaults. #[test] diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 14debc498..e9323a210 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -16,7 +16,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{Receiver, channel}; use lightcraft_ui_egui::LightcraftApp; -use lightcraft_ui_egui::menubar::{MenuNode, menu_bar}; +use lightcraft_ui_egui::menubar::{ + MenuNode, menu_bar, native_item_presentation, native_menu_delivers as menu_delivers, native_menu_structure as structure_of, +}; use muda::accelerator::{Accelerator, Code, Modifiers}; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use serde_json::Value; @@ -57,8 +59,10 @@ struct Item { } impl Item { + /// The row's text as installed: `label` is already what the row shows (translated, with a + /// borrowed key's hint, `native_item_presentation`). fn native_label(&self) -> String { - lightcraft_ui_egui::menubar::display_item_label(&self.cmd, &self.params, &self.label).replace('&', "&&") + self.label.replace('&', "&&") } /// Puts `want` on the native item. muda's `set_accelerator(None)` does nothing on macOS (the @@ -276,13 +280,6 @@ fn yields_to_text(sc: &str) -> bool { !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) } -/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function -/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run -/// those even though their menu item shows the key. -fn menu_delivers(sc: &str) -> bool { - sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok())) -} - /// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`. fn owned_by_menu<'a>(installed: impl Iterator, text_focus: bool) -> HashSet { installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect() @@ -293,34 +290,6 @@ fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") } -fn structure_of(bar: &[(String, Vec)]) -> String { - fn walk(n: &[MenuNode], out: &mut String) { - for x in n { - match x { - MenuNode::Item { id, params, checked, shortcut, .. } => { - out.push_str(&MenuNode::key(id, params)); - out.push(if checked.is_some() { 'c' } else { 'i' }); - out.push_str(shortcut.as_deref().unwrap_or("")); - } - MenuNode::Separator => out.push('-'), - MenuNode::Submenu { label, children } => { - out.push('['); - out.push_str(label); - walk(children, out); - out.push(']'); - } - } - out.push(';'); - } - } - let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code()); - for (t, items) in bar { - s.push_str(t); - walk(items, &mut s); - } - s -} - /// Drop a trailing separator (left behind when an item moved to the app menu). fn tidy_separators(mut nodes: Vec) -> Vec { while matches!(nodes.last(), Some(MenuNode::Separator)) { @@ -353,9 +322,10 @@ impl NativeMenu { m } + /// Recreate native handles and shortcut ownership from the current shared menu model. fn rebuild(&mut self, app: &mut LightcraftApp) { let bar = menu_bar(app); - self.structure = structure_of(&bar); + self.structure = structure_of(&bar, &app.ui.settings.keymap); self.menu = Menu::new(); self.items.clear(); self.app_items.clear(); @@ -389,7 +359,7 @@ impl NativeMenu { shortcut: Some(shortcut.to_string()), accel, current: accel, - label: text.to_string(), + label: lightcraft_ui_egui::menubar::display_item_label(cmd, &Value::Null, text).to_string(), enabled: true, checked: None, }); @@ -414,7 +384,7 @@ impl NativeMenu { let nodes = tidy_separators(nodes); // Swapping an item in the Help menu leaves the old row in place and adds a second one // (measured on macOS 27 with muda 0.21): the Help menu is not addressed row for row. - self.append_nodes(&sub, &nodes, title != "Help"); + self.append_nodes(&sub, &nodes, &app.ui.settings.keymap, title != "Help"); if title == "File" { // ⌘W, the system's own item let _ = sub.append_items(&[ @@ -441,8 +411,9 @@ impl NativeMenu { self.publish_shortcuts(app); } + /// Install translated rows with their effective accelerators, preserving hint-only keys. /// `swappable`: whether its items may be replaced to drop their key (see `Item::swappable`). - fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], swappable: bool) { + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], keymap: &lightcraft_ui_egui::shortcuts::Keymap, swappable: bool) { for n in nodes { match n { MenuNode::Separator => { @@ -450,7 +421,7 @@ impl NativeMenu { } MenuNode::Submenu { label, children } => { let s = Submenu::new(label_text(label), true); - self.append_nodes(&s, children, swappable); + self.append_nodes(&s, children, keymap, swappable); let _ = sub.append(&s); } MenuNode::Item { id, params, label, shortcut, enabled, checked } => { @@ -458,26 +429,16 @@ impl NativeMenu { if self.items.contains_key(&key) { continue; // muda ids must be unique } - let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); + let (native_label, native_key) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); + let accel = native_key.filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); let handle = match checked { Some(c) => { - let it = CheckMenuItem::with_id( - key.clone(), - lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"), - *enabled, - *c, - accel, - ); + let it = CheckMenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, *c, accel); let _ = sub.append(&it); Handle::Check(it) } None => { - let it = MenuItem::with_id( - key.clone(), - lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&"), - *enabled, - accel, - ); + let it = MenuItem::with_id(key.clone(), native_label.replace('&', "&&"), *enabled, accel); let _ = sub.append(&it); Handle::Plain(it) } @@ -493,7 +454,7 @@ impl NativeMenu { shortcut: shortcut.clone(), accel, current: accel, - label: label.clone(), + label: native_label, enabled: *enabled, checked: *checked, }, @@ -533,23 +494,25 @@ impl NativeMenu { } } let bar = menu_bar(app); - if structure_of(&bar) != self.structure { + if structure_of(&bar, &app.ui.settings.keymap) != self.structure { // (no early return: a rebuild installs every accelerator and resets the state they were synced // to, so the sync below runs in this same frame and no key reaches the menu in between) self.rebuild(app); } - fn walk(nodes: &[MenuNode], items: &mut HashMap) { + /// Synchronize live labels and states; accelerator ownership changes already triggered a rebuild. + fn walk(nodes: &[MenuNode], items: &mut HashMap, keymap: &lightcraft_ui_egui::shortcuts::Keymap) { for n in nodes { match n { - MenuNode::Submenu { children, .. } => walk(children, items), - MenuNode::Item { id, params, label, enabled, checked, .. } => { + MenuNode::Submenu { children, .. } => walk(children, items, keymap), + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; - if it.label != *label { + let (native_label, _) = native_item_presentation(keymap, id, params, label, shortcut.as_deref()); + if it.label != native_label { match &it.handle { - Handle::Plain(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")), - Handle::Check(h) => h.set_text(lightcraft_ui_egui::menubar::display_item_label(id, params, label).replace('&', "&&")), + Handle::Plain(h) => h.set_text(native_label.replace('&', "&&")), + Handle::Check(h) => h.set_text(native_label.replace('&', "&&")), } - it.label = label.clone(); + it.label = native_label; } if it.enabled != *enabled { match &it.handle { @@ -570,7 +533,7 @@ impl NativeMenu { } } } - walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); + walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items, &app.ui.settings.keymap); let focus = ctx.egui_wants_keyboard_input(); let capturing = app.recording_shortcut.is_some(); @@ -610,7 +573,7 @@ mod tests { let mut seen = std::collections::BTreeSet::new(); for language in Locale::ALL { set_language(*language); - assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); + assert!(seen.insert(structure_of(&bar, &app.ui.settings.keymap)), "{language:?} shares another language's menu structure key"); } set_language(Locale::En); } diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 1f84ec477..fefa65a51 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -93,7 +93,6 @@ pub fn choose(files: &Files) -> Chosen { let compiled = wgpu::Instance::enabled_backend_features(); let os = std::env::consts::OS; let order = backend::window_renderer_order(forced, remembered, failed, os, compiled, true); - let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); let mut problems = Vec::new(); let chosen = if cfg!(windows) { pick_probed(&order, &mut problems) @@ -104,18 +103,7 @@ pub fn choose(files: &Files) -> Chosen { Some((WindowRenderer::Wgpu(backend::window_backends()), None)) }; let (which, existing) = chosen.unwrap_or((WindowRenderer::Glow, None)); - if !problems.is_empty() { - notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string()); - } - let notice = notice.map(|n| { - format!( - "{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \ - (see docs/gpu-pipeline.md → Troubleshooting).", - which.name(), - app = "LightCraft", - var = "LIGHTCRAFT_GPU_BACKEND", - ) - }); + let notice = user_notice(failed, forced, &problems, which); log::info!("window: renderer {} (order {:?})", which.name(), order.iter().map(|r| r.name()).collect::>()); let mut wgpu_options = base_options(); let renderer = match (which, existing) { @@ -134,6 +122,25 @@ pub fn choose(files: &Files) -> Chosen { Chosen { renderer, wgpu_options, which, notice } } +/// What to tell the user about the window's renderer. A renderer that failed its probe is skipped +/// silently (it is logged, and the one that worked is remembered): the app works, so a pop-up with +/// wgpu's error only alarms (#1624). Shown: the last start died, or the forced renderer failed. +fn user_notice(failed: Option, forced: Option, problems: &[String], which: WindowRenderer) -> Option { + let mut notice = failed.map(|f| format!("The window could not start with its {} renderer last time.", f.name())); + if forced.is_some() && !problems.is_empty() { + notice = Some(format!("{} {}", notice.unwrap_or_default(), problems.join(" ")).trim().to_string()); + } + notice.map(|n| { + format!( + "{n} {app} now draws its window with {}. To choose another, start {app} with {var}=dx12, vulkan, gl or glow \ + (see docs/gpu-pipeline.md → Troubleshooting).", + which.name(), + app = "LightCraft", + var = "LIGHTCRAFT_GPU_BACKEND", + ) + }) +} + /// eframe's defaults with the app's backends and DX12 shader compiler (issues #136, #471). pub fn base_options() -> WgpuConfiguration { let mut c = WgpuConfiguration::default(); @@ -269,6 +276,20 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// #1624: a renderer skipped by the probe (DX12 → Vulkan) is no pop-up; a died start or a + /// failed forced renderer still is. + #[test] + fn probe_fallback_is_silent() { + let (dx, vk) = (WindowRenderer::Wgpu(Backends::DX12), WindowRenderer::Wgpu(Backends::VULKAN)); + let problems = vec!["The dx12 renderer failed (Validation Error).".to_string()]; + assert_eq!(user_notice(None, None, &problems, vk), None); + assert_eq!(user_notice(None, None, &[], vk), None); + let forced = user_notice(None, Some(dx), &problems, vk).unwrap(); + assert!(forced.starts_with("The dx12 renderer failed") && forced.contains("now draws its window with vulkan"), "{forced}"); + let died = user_notice(Some(dx), None, &problems, vk).unwrap(); + assert!(died.contains("last time") && !died.contains("Validation Error"), "{died}"); + } + /// The crash report: a pipeline that fails validation must come back as an error, never as /// wgpu's default panic. Needs an adapter; skipped where there is none (CI without a GPU). #[test] diff --git a/contributors/people.toml b/contributors/people.toml index cc71e4cc2..0b7f9ee43 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,6 +61,9 @@ sync_github_name = false real_name = "Ege Gursel" sync_github_name = true +[people.valenfys] +sync_github_name = true + [people.mahmatias] display_name = "Márcio Matias de Andrade" sync_github_name = true diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 2b71049ee..2dfebbf90 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -43,19 +43,63 @@ pub fn weekday(iso: &str) -> Option<&'static str> { /// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was. /// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years /// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be -/// anything, it never overflows. +/// anything, it never overflows. Stricter than [`iso_seconds`], which reads what it can for sorting: +/// a time cut short, seconds that aren't digits or anything after the zone is `None` here, not +/// written back as a clean-looking time. pub fn shift_iso(iso: &str, secs: i64) -> Option { + let iso = iso.trim(); + let tail = iso.get(tail_start(iso)?..)?; let t = iso_seconds(iso)?.checked_add(secs)?; let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?); if !(first..=last).contains(&t) { return None; } - let iso = iso.trim(); - // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes - let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or(""); Some(format!("{}{tail}", civil(t))) } +/// Where the part of an ISO time that [`shift_iso`] keeps as it was (fraction, zone) starts, or +/// `None` unless `iso` is a date alone (`2026-01-01`) or a date and `HH:MM`, optional `:SS` with +/// an optional fraction (`.5`), and an optional zone (`Z`, `+02:00`, `-0500`, `+02`), and nothing after. +fn tail_start(iso: &str) -> Option { + let b = iso.as_bytes(); + let digits = |x: &[u8]| !x.is_empty() && x.iter().all(u8::is_ascii_digit); + let digits_at = |a: usize, z: usize| b.get(a..z).is_some_and(digits); + let at = |i: usize, c: u8| b.get(i) == Some(&c); + if !(digits_at(0, 4) && at(4, b'-') && digits_at(5, 7) && at(7, b'-') && digits_at(8, 10)) { + return None; + } + if b.len() == 10 { + return Some(10); + } + if !((at(10, b'T') || at(10, b' ')) && digits_at(11, 13) && at(13, b':') && digits_at(14, 16)) { + return None; + } + // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes + let start = if !at(16, b':') { + 16 + } else if digits_at(17, 19) { + 19 + } else { + return None; + }; + let mut zone = b.get(start..)?; + // a fraction only after the seconds: `10:00.5` is half a minute, not half a second + if let ([b'.', rest @ ..], 19) = (zone, start) { + let n = rest.iter().take_while(|c| c.is_ascii_digit()).count(); + zone = rest.get(n..)?; + if n == 0 { + return None; + } + } + let ok = match zone { + [] | [b'Z'] => true, + [b'+' | b'-', h1, h2, b':', m1, m2] => digits(&[*h1, *h2, *m1, *m2]), + [b'+' | b'-', hm @ ..] => matches!(hm.len(), 2 | 4) && digits(hm), + _ => false, + }; + ok.then_some(start) +} + /// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing /// seconds or time). `None` if it isn't a valid date. pub fn normalize_iso(s: &str) -> Option { @@ -260,6 +304,65 @@ mod tests { assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00")); } + #[test] + fn shift_refuses_a_time_it_cant_fully_read() { + for bad in [ + "2026-01-01T10:0", + "2026-01-01T10", + "2026-01-01T", + "2026-01-01T10:00:xx", + "2026-01-01T10:00:0", + "2026-01-01T10:00:00+", + "2026-01-01T10:00:00+2", + "2026-01-01T10:00:00+02:0", + "2026-01-01T10:00:00.", + "2026-01-01T10:00:00.5.5", + "2026-01-01T10:00.5", + "2026-01-01T10:00:00ZZ", + "2026-01-01T10:00:00 and more", + "2026-01-01T10:00 and more", + "2026-01-01x10:00:00", + "2026/01/01T10:00:00", + ] { + assert_eq!(shift_iso(bad, 3600), None, "{bad} doesn't fully read, so it must not be written back"); + } + // multi-byte characters where the time, the seconds and the zone start never panic + for at in [11, 16, 18, 21] { + let mut s = String::from("2026-01-01T10:00:00+02:00"); + s.replace_range(at..at + 1, "é"); + assert_eq!(shift_iso(&s, 3600), None, "{s}"); + assert_eq!(shift_iso(s.get(..at + 2).unwrap(), 3600), None, "{s}"); + } + } + + #[test] + fn shift_keeps_a_fraction_and_zone_and_reads_back() { + for (from, to) in [ + ("2026-01-01", "2026-01-01T01:00:00"), + (" 2026-01-01T10:00 ", "2026-01-01T11:00:00"), + ("2026-01-01 10:00:30", "2026-01-01T11:00:30"), + ("2026-01-01T10:00:30.5", "2026-01-01T11:00:30.5"), + ("2026-01-01T10:00:30Z", "2026-01-01T11:00:30Z"), + ("2026-01-01T10:00:30+02:00", "2026-01-01T11:00:30+02:00"), + ("2026-01-01T10:00:30-0500", "2026-01-01T11:00:30-0500"), + ("2026-01-01T10:00:30+02", "2026-01-01T11:00:30+02"), + ("2026-01-01T10:00:30.125+02:00", "2026-01-01T11:00:30.125+02:00"), + ] { + assert_eq!(shift_iso(from, 3600).as_deref(), Some(to), "{from}"); + assert_eq!(shift_iso(to, 0).as_deref(), Some(to), "{to} reads back"); + } + } + + /// Sorting, grouping and date rules read with `iso_seconds`, which stays lenient: only the + /// write path refuses what it can't fully read. + #[test] + fn reading_stays_lenient_for_sorting() { + let at_ten = iso_seconds("2026-01-01T10:00:00"); + assert_eq!(iso_seconds("2026-01-01T10:00:00 and more"), at_ten); + assert_eq!(iso_seconds("2026-01-01T10:00:xx"), at_ten); + assert_eq!(iso_seconds("2026-01-01T10:0"), iso_seconds("2026-01-01")); + } + #[test] fn impossible_times_are_rejected_not_rolled_over() { for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] { diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ca581b06e..8539a5b6a 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -1193,4 +1193,6 @@ mod tests_repair; #[cfg(test)] mod tests_scale; #[cfg(test)] +mod tests_tally; +#[cfg(test)] mod tests_torn_append; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index bdf4b8c88..2976a2659 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -432,12 +432,79 @@ thread_local! { std::cell::RefCell::new(std::collections::HashMap::new()); } +thread_local! { + /// While [`AlbumPass`] is alive on this thread: the photos of each smart album that was counted whole, with + /// the longest chain of smart albums (itself included) its rules lead through. + static PASS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// One pass over one catalog state that counts every smart album whole. Each album put in with `remember` +/// answers [`smart_album_holds`] from its photos, not from its rules, until the pass is dropped (it can't outlive +/// the state it was made of: it exists only inside the counting function). +/// +/// Only an album whose rules lead to no loop may be remembered, and only with the height of its chain: its answer +/// then doesn't depend on which albums are being evaluated around it, as long as the chain still fits under +/// [`MAX_ALBUM_DEPTH`] from where it is asked (checked on every use; otherwise the rules are evaluated as before). +pub(crate) struct AlbumPass { + outer: Option>, +} + +impl AlbumPass { + pub(crate) fn begin() -> AlbumPass { + AlbumPass { outer: PASS.with_borrow_mut(|p| p.replace(std::collections::HashMap::new())) } + } + + /// `id` holds exactly `members`, and its rules lead through a chain of `height` smart albums (itself included), + /// none of them in a loop. + pub(crate) fn remember(&self, id: crate::AlbumId, height: usize, members: crate::tally::Members) { + PASS.with_borrow_mut(|p| { + if let Some(p) = p { + p.insert(id, (height, members)); + } + }); + } + + /// Ends the pass, giving back what was remembered. + pub(crate) fn finish(self) -> std::collections::HashMap { + PASS.with_borrow_mut(Option::take).unwrap_or_default().into_iter().map(|(id, (_, members))| (id, members)).collect() + } +} + +impl Drop for AlbumPass { + fn drop(&mut self) { + let outer = self.outer.take(); + PASS.with_borrow_mut(|p| *p = outer); + } +} + +/// What a counting pass knows of smart album `id` for `photo`, when asking now can't give another answer than +/// evaluating its rules would: the chain below it fits under [`MAX_ALBUM_DEPTH`] from the albums being evaluated. +fn pass_answer(id: crate::AlbumId, photo: crate::PhotoId) -> Option { + PASS.with_borrow(|p| { + let (height, members) = p.as_ref()?.get(&id)?; + let depth = EVALUATING.with_borrow(Vec::len); + (depth + height <= MAX_ALBUM_DEPTH).then(|| members.holds(photo)) + }) +} + +#[cfg(test)] +thread_local! { + /// How many times a smart album's rules were worked out on this thread (tests count them). + pub(crate) static EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the /// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing). pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool { + if let Some(known) = pass_answer(id, photo) { + return known; + } if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) { return known; } + #[cfg(test)] + EVALUATED.with(|n| n.set(n.get() + 1)); // the outermost question forgets the answers when it ends, however it ends struct Forget(bool); impl Drop for Forget { @@ -456,7 +523,7 @@ pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, match } /// How deep smart albums may test smart albums before the innermost counts as holding nothing. -const MAX_ALBUM_DEPTH: usize = 32; +pub(crate) const MAX_ALBUM_DEPTH: usize = 32; /// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id` /// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is diff --git a/crates/catalog/src/tally.rs b/crates/catalog/src/tally.rs index 8f15e5ec4..e8fdc6197 100644 --- a/crates/catalog/src/tally.rs +++ b/crates/catalog/src/tally.rs @@ -8,7 +8,9 @@ use std::collections::{HashMap, HashSet}; -use crate::{AlbumId, Aspects, Catalog, PhotoId}; +use crate::query::Filter; +use crate::rules::{AlbumPass, MAX_ALBUM_DEPTH}; +use crate::{Album, AlbumId, Aspects, Catalog, PhotoId}; /// The most photos an edit may touch for the counts to follow it photo by photo. const MAX_FOLLOWED: usize = 256; @@ -20,7 +22,7 @@ const WORDS_TO_START: usize = 1024; /// The photos a smart album holds. #[derive(Clone, Debug)] -enum Members { +pub(crate) enum Members { /// One bit a photo id, and how many are set. Bits { words: Vec, @@ -41,8 +43,7 @@ impl Members { } } - #[cfg(test)] - fn holds(&self, id: PhotoId) -> bool { + pub(crate) fn holds(&self, id: PhotoId) -> bool { match self { Members::Bits { words, .. } => { usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0) @@ -114,21 +115,46 @@ pub struct AlbumTally { impl AlbumTally { /// Counted from every photo. + /// + /// A smart album that tests another (`album is #14`) reads that album's photos, counted once for the whole + /// pass, instead of working its rules out again for every photo: albums are counted shortest chain first. + /// The photos are kept only until this function returns, so they can't be older than the catalog counted. + /// An album whose rules lead into a loop, or through a chain too deep to be sure of, is counted by its rules + /// as before: the pass ends before the first of them (they sort last), so nothing they evaluate reads it. pub fn of(cat: &Catalog) -> AlbumTally { + let heights = chain_heights(cat); + let height_of = |id: AlbumId| heights.get(&id).copied().flatten().filter(|h| *h <= MAX_ALBUM_DEPTH); + let mut smart: Vec = cat.albums().filter(|a| a.is_smart()).map(|a| a.id).collect(); + smart.sort_by_key(|id| height_of(*id).unwrap_or(usize::MAX)); + let held_by = |id: AlbumId| { + let mut held = Members::none(); + for p in cat.photos().filter(|p| cat.album_contains(id, p)) { + held.set(p.id, true); + } + held + }; + let mut counts = HashMap::new(); let mut members = HashMap::new(); - for album in cat.albums() { - if album.is_smart() { - let mut held = Members::none(); - for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) { - held.set(p.id, true); - } - counts.insert(album.id, held.len()); - members.insert(album.id, held); - } else { - counts.insert(album.id, cat.album_count(album.id)); + let split = smart.partition_point(|id| height_of(*id).is_some()); + let (with_height, without) = smart.split_at(split); + let pass = AlbumPass::begin(); + for id in with_height { + let held = held_by(*id); + counts.insert(*id, held.len()); + if let Some(height) = height_of(*id) { + pass.remember(*id, height, held); } } + members.extend(pass.finish()); + for id in without { + let held = held_by(*id); + counts.insert(*id, held.len()); + members.insert(*id, held); + } + for album in cat.albums().filter(|a| !a.is_smart()) { + counts.insert(album.id, cat.album_count(album.id)); + } AlbumTally { revision: cat.revision, counts, members } } @@ -184,6 +210,57 @@ impl AlbumTally { } } +/// For each smart album, the most smart albums in a chain its rules lead through (itself included), or `None` +/// when a loop is reachable from it (its answer may then depend on which albums are being evaluated around it). +fn chain_heights(cat: &Catalog) -> HashMap> { + struct Frame { + id: AlbumId, + tests: Vec, + next: usize, + tallest: usize, + loops: bool, + } + let tests = |id: AlbumId| -> Vec { + let tested = cat.album(id).and_then(|a| a.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default(); + tested.into_iter().filter(|t| cat.album(*t).is_some_and(Album::is_smart)).collect() + }; + let frame = |id: AlbumId| Frame { id, tests: tests(id), next: 0, tallest: 0, loops: false }; + let mut done: HashMap> = HashMap::new(); + let mut open: HashSet = HashSet::new(); + for root in cat.albums().filter(|a| a.is_smart()).map(|a| a.id) { + if done.contains_key(&root) { + continue; + } + open.insert(root); + let mut stack = vec![frame(root)]; + while let Some(top) = stack.last_mut() { + if let Some(&kid) = top.tests.get(top.next) { + top.next += 1; + match done.get(&kid) { + Some(Some(h)) => top.tallest = top.tallest.max(*h), + Some(None) => top.loops = true, + None if open.contains(&kid) => top.loops = true, + None => { + open.insert(kid); + stack.push(frame(kid)); + } + } + } else if let Some(finished) = stack.pop() { + open.remove(&finished.id); + let height = (!finished.loops).then_some(finished.tallest + 1); + done.insert(finished.id, height); + if let Some(parent) = stack.last_mut() { + match height { + Some(h) => parent.tallest = parent.tallest.max(h), + None => parent.loops = true, + } + } + } + } + } + done +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/catalog/src/tests_tally.rs b/crates/catalog/src/tests_tally.rs new file mode 100644 index 000000000..4953a8bd1 --- /dev/null +++ b/crates/catalog/src/tests_tally.rs @@ -0,0 +1,172 @@ +//! A whole count of every album (`AlbumTally::of`) keeps each smart album's photos for the pass, so an album testing +//! another doesn't work its rules out again. The counts must be the ones each album gets counted alone. + +use serde_json::json; + +use super::*; +use crate::tally::AlbumTally; + +fn smart(id: u64, rules: serde_json::Value) -> Album { + let rule_set = Some(serde_json::from_value(rules).unwrap()); + Album { smart: Some(Box::new(Filter { rule_set, ..Default::default() })), ..Album::new(AlbumId(id), format!("Smart {id}")) } +} + +fn photos(n: u64) -> Vec { + (0..n) + .map(|i| { + // folders with capitals, accents and a Windows path, so the text rules see every spelling + let dir = ["Asian/Japanese", "Été/Ünï", "C:\\Users\\Me\\Pictures", "plain", "[SITES]/x"][(i % 5) as usize]; + let mut p = Photo::new( + PhotoId(i + 1), + Source::File { path: format!("/Volumes/share/photos/{dir}/name{}/IMG_{i:04}.jpg", i % 9) }, + &format!("IMG_{i:04}.jpg"), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.rating = (i % 6) as u8; + p.flag = [Flag::None, Flag::Pick, Flag::Reject][(i % 3) as usize]; + p.label = (i % 4 == 0).then_some(ColorLabel::Red); + p.meta.keywords = (0..i % 4).map(|k| format!("kw{k}")).collect(); + p.deleted = i % 37 == 0; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect() +} + +fn is(album: u64) -> serde_json::Value { + json!({"field": "album", "op": "is", "value": album}) +} + +fn is_not(album: u64) -> serde_json::Value { + json!({"field": "album", "op": "isNot", "value": album}) +} + +fn path(op: &str, value: &str) -> serde_json::Value { + json!({"field": "filePath", "op": op, "value": value}) +} + +/// Every album's count in `c`, each album counted alone (no pass, rules worked out per photo). +fn counted_alone(c: &Catalog) -> Vec<(AlbumId, usize)> { + let mut v: Vec<_> = c.albums().map(|a| (a.id, c.album_count(a.id))).collect(); + v.sort_unstable(); + v +} + +fn counted_together(c: &Catalog) -> Vec<(AlbumId, usize)> { + let tally = AlbumTally::of(c); + let mut v: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect(); + v.sort_unstable(); + v +} + +#[test] +fn a_whole_count_equals_each_album_counted_alone() { + let mut c = Catalog::new(); + let mut ops = photos(240); + let mut plain = Album::new(AlbumId(1), "Picks"); + plain.photos = (0..240).step_by(4).map(|i| PhotoId(i + 1)).collect(); + let folder = Album { folder: true, ..Album::new(AlbumId(2), "Trips") }; + let inside = Album { parent: Some(AlbumId(2)), photos: (0..240).step_by(3).map(|i| PhotoId(i + 1)).collect(), ..Album::new(AlbumId(3), "In") }; + ops.extend([plain, folder, inside].map(|album| Op::AddAlbum { album })); + // the inner albums: #2-like, #14-like, then albums chained on it, the other way round, in groups and in different orders + ops.push(Op::AddAlbum { + album: smart( + 10, + json!({"match": "any", "rules": [{"field": "flag", "op": "is", "value": "reject"}, {"field": "label", "op": "is", "value": "red"}]}), + ), + }); + ops.push(Op::AddAlbum { + album: smart(11, json!({"match": "all", "rules": [is_not(10), path("notContains", "Été"), path("notContains", "plain")]})), + }); + for k in 0..9u64 { + let tail = path("contains", &format!("NAME{k}")); + let rules = match k % 3 { + 0 => json!({"match": "all", "rules": [is(11), tail]}), + 1 => json!({"match": "all", "rules": [json!({"field": "keywordCount", "op": "lte", "value": 2}), is(11), tail]}), + _ => json!({"match": "all", "rules": [tail, is(11)]}), + }; + ops.push(Op::AddAlbum { album: smart(12 + k, rules) }); + } + ops.push(Op::AddAlbum { + album: smart( + 21, + json!({"match": "all", "rules": [{"group": {"match": "any", "rules": [is(11), is(1)]}}, {"field": "rating", "op": "gte", "value": 2}]}), + ), + }); + ops.push(Op::AddAlbum { album: smart(22, json!({"match": "all", "rules": [is_not(21), is(3)]})) }); + ops.push(Op::AddAlbum { album: smart(23, json!({"match": "any", "rules": [is(2), is(999), is(1)]})) }); + // a loop saved anyway (30 ↔ 31), albums that test it from outside (32, 33), and one that tests both worlds + ops.push(Op::AddAlbum { album: smart(30, json!({"rules": []})) }); + ops.push(Op::AddAlbum { album: smart(31, json!({"rules": [is_not(30)]})) }); + ops.push(Op::AddAlbum { album: smart(32, json!({"rules": [is(31), {"field": "rating", "op": "gte", "value": 1}]})) }); + ops.push(Op::AddAlbum { album: smart(33, json!({"rules": [is_not(32), is(11)]})) }); + ops.push(Op::AddAlbum { album: smart(34, json!({"rules": [is(34)]})) }); + // a chain deeper than the limit on how deep albums may test albums + ops.push(Op::AddAlbum { album: smart(100, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) }); + for k in 101..=140u64 { + ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k - 1)]})) }); + } + ops.push(Op::AddAlbum { album: smart(141, json!({"rules": [is(140), is_not(11)]})) }); + // an album past the depth limit that also tests a memoized album directly: where the shared album is read from the + // pass near the top, the chain below must still find it answered as it always did (200 ← 231 … 201, height 32) + ops.push(Op::AddAlbum { album: smart(200, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) }); + for k in 201..=230u64 { + ops.push(Op::AddAlbum { album: smart(k, json!({"rules": [is(k + 1)]})) }); + } + ops.push(Op::AddAlbum { album: smart(231, json!({"rules": [is(200)]})) }); + ops.push(Op::AddAlbum { album: smart(250, json!({"match": "all", "rules": [is(200), is(201)]})) }); + c.apply(Op::Batch { ops }).unwrap(); + c.apply(Op::SetAlbumRules { + id: AlbumId(30), + rules: Box::new(Filter { rule_set: Some(serde_json::from_value(json!({"rules": [is_not(31)]})).unwrap()), ..Default::default() }), + }) + .unwrap(); + + let alone = counted_alone(&c); + let count = |id: u64| alone.iter().find(|(a, _)| a.0 == id).map(|(_, n)| *n); + assert!(count(11) > Some(0) && count(131) > Some(0), "{alone:?}"); + assert_eq!(count(132), Some(0), "the chain is too deep from here: holds nothing, as it did"); + assert!(count(250) > Some(0), "200 is answered near the top, so the chain finds it answered"); + assert_eq!(counted_together(&c), alone); + // the pass leaves nothing behind: the same answers whichever album asks first, and again after an edit + c.apply(Op::SetAlbumRules { + id: AlbumId(10), + rules: Box::new(Filter { + rule_set: Some(serde_json::from_value(json!({"rules": [{"field": "rating", "op": "is", "value": 5}]})).unwrap()), + ..Default::default() + }), + }) + .unwrap(); + let after = counted_alone(&c); + assert_ne!(after, alone); + assert_eq!(counted_together(&c), after); +} + +/// The reason for the pass: with an album tested by many, its rules are worked out once per photo, not once per +/// photo for each album that tests it. +#[test] +fn an_album_tested_by_many_is_worked_out_once_a_photo() { + let mut c = Catalog::new(); + let mut ops: Vec = (0..60u64) + .map(|i| { + let mut p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-01-01T00:00:00"); + p.rating = (i % 6) as u8; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + ops.push(Op::AddAlbum { album: smart(1, json!({"rules": [{"field": "rating", "op": "gte", "value": 1}]})) }); + for k in 0..10u64 { + ops.push(Op::AddAlbum { album: smart(2 + k, json!({"rules": [is(1), {"field": "rating", "op": "lte", "value": k % 6}]})) }); + } + c.apply(Op::Batch { ops }).unwrap(); + let alone = counted_alone(&c); + crate::rules::EVALUATED.with(|n| n.set(0)); + let tally = AlbumTally::of(&c); + let worked = crate::rules::EVALUATED.with(std::cell::Cell::get); + assert_eq!(worked, 11 * 60, "each of the 11 smart albums asks each of the 60 photos once"); + let mut together: Vec<_> = tally.counts().iter().map(|(id, n)| (*id, *n)).collect(); + together.sort_unstable(); + assert_eq!(together, alone); +} diff --git a/crates/engine/src/availability.rs b/crates/engine/src/availability.rs index 5976a3b2e..3cf5ce19c 100644 --- a/crates/engine/src/availability.rs +++ b/crates/engine/src/availability.rs @@ -7,6 +7,16 @@ //! there"; the render worker finds out if it isn't), known answers are rechecked after //! [`RECHECK`]. Everywhere else (CLI, MCP, tests) the default is synchronous: every question is a //! fresh check, as before. +//! +//! The same worker can check a list of files, such as every library photo's, now and then +//! ([`Availability::sweep`]), one at a time and only while nothing is asked. That is for the +//! Missing Photos count ([`Availability::is_gone`]): one stream of checks on a share, not one per +//! thing that wants to know. The app starts the sweeps; nothing here does. +//! +//! A sweep of a large library on a share takes tens of minutes, and its `stat`s compete with the +//! reads of an import or a scan on the same share. So the worker starts no sweep check while the +//! caller says files are busy ([`Availability::sweep_yields_to`]); what is asked still gets +//! answered (within [`YIELD_POLL`]), and the sweep goes on where it stopped when the work ends. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -14,9 +24,16 @@ use std::time::Duration; /// Checks whether a path exists (injectable: tests use a slow one). pub type ExistsProbe = Arc bool + Send + Sync>; -/// Called (from the worker) when an answer changed, e.g. to repaint the UI. +/// Called (from the worker) when an answer changed, e.g. to repaint the UI (a sweep's first look +/// at a file that is there changes no answer anyone sees, so it wakes nobody). pub type Waker = Arc; +/// Whether file work that a sweep should not compete with is going on. +pub type Busy = Arc bool + Send + Sync>; + +/// How often a sweep that is giving way looks whether the work has ended. +const YIELD_POLL: Duration = Duration::from_millis(250); + /// How long a cached answer is used before it is checked again (it is still returned meanwhile). pub const RECHECK: Duration = Duration::from_secs(15); @@ -31,9 +48,17 @@ struct State { known: HashMap, queue: VecDeque, queued: HashSet, + /// What a sweep has left to check: after the queue, whatever it holds. + sweep: VecDeque, + /// The files found not there (and not found there since). + gone: HashSet, worker: bool, - /// Bumped whenever an answer changes (callers recompute what depends on it). - generation: u64, + /// Bumped when the sweep under way is dropped for another one ([`Availability::restart_sweeps`]). + sweep_epoch: u64, + /// Whether file work a sweep gives way to is going on. + busy: Option, + /// Bumped whenever a file is found gone, or found back. + gone_generation: u64, } fn lock(m: &Mutex) -> MutexGuard<'_, State> { @@ -66,7 +91,8 @@ impl Availability { self.probe = probe; let mut st = lock(&self.state); st.known.clear(); - st.generation += 1; + st.gone.clear(); + st.gone_generation += 1; } /// Answer from the cache and check on a worker thread from now on; `wake` runs when an @@ -78,6 +104,14 @@ impl Availability { self.background = Some(wake); } + /// The sweep starts no check while `busy` says so (the caller's import, scan or Local read in + /// flight); asked files are answered all the same, and the sweep resumes where it was left when + /// `busy` turns false. `busy` should read state that a task's guard owns (the activity stack), so + /// a task that never ends cleanly can't hold the sweep for good. + pub fn sweep_yields_to(&self, busy: Busy) { + lock(&self.state).busy = Some(busy); + } + /// The existence check itself (blocking: for worker threads, e.g. the Rename preview's). pub fn probe(&self) -> ExistsProbe { self.probe.clone() @@ -99,12 +133,7 @@ impl Availability { if cached.is_none_or(|(_, at)| now.saturating_duration_since(at) >= RECHECK) && !st.queued.contains(path) { st.queued.insert(path.to_string()); st.queue.push_back(path.to_string()); - if !st.worker { - st.worker = true; - drop(st); - self.spawn_worker(wake.clone()); - return cached.map(|c| c.0); - } + self.start(st, wake); } cached.map(|c| c.0) } @@ -114,9 +143,10 @@ impl Availability { self.exists(path) == Some(false) } - /// Changes whenever a cached answer changes. - pub fn generation(&self) -> u64 { - lock(&self.state).generation + /// Changes whenever a file is found gone or found back: what [`Availability::is_offline`] + /// says changed for some file. (A file found there at its first check changes nothing.) + pub fn gone_generation(&self) -> u64 { + lock(&self.state).gone_generation } /// Paths waiting for the worker. @@ -124,37 +154,123 @@ impl Availability { lock(&self.state).queue.len() } + /// Check `paths` too, in the background and after whatever is asked meanwhile, skipping those + /// checked less than [`RECHECK`] ago. One sweep at a time: `false` (and nothing queued) while + /// one is under way, or in synchronous mode. A sweep's answers wake only when a file is found + /// gone or back. + pub fn sweep(&self, paths: Vec) -> bool { + self.sweep_checked(None, paths) + } + + fn sweep_checked(&self, epoch: Option, paths: Vec) -> bool { + let Some(wake) = &self.background else { return false }; + let mut st = lock(&self.state); + if !st.sweep.is_empty() || epoch.is_some_and(|e| e != st.sweep_epoch) { + return false; + } + st.sweep = paths.into(); + self.start(st, wake); + true + } + + /// Drop what the sweep under way has left (another library was opened: its files are not the + /// ones to check now) and say which sweeps may start from here ([`Availability::sweep_in`]): a + /// list of files being made for the old one is then refused. + pub fn restart_sweeps(&self) -> u64 { + let mut st = lock(&self.state); + st.sweep.clear(); + st.sweep_epoch += 1; + st.sweep_epoch + } + + /// [`Availability::sweep`], unless [`Availability::restart_sweeps`] was called since it + /// returned `epoch`. + pub fn sweep_in(&self, epoch: u64, paths: Vec) -> bool { + self.sweep_checked(Some(epoch), paths) + } + + /// Whether the file at `path` was found gone (and not found there since): `false` for a file + /// not checked yet. + pub fn is_gone(&self, path: &str) -> bool { + lock(&self.state).gone.contains(path) + } + + /// How many files are found gone. + pub fn gone_len(&self) -> usize { + lock(&self.state).gone.len() + } + + /// What the sweep under way has left to check (0: none under way, though its last file may still + /// be in flight). + pub fn sweeping(&self) -> usize { + lock(&self.state).sweep.len() + } + + /// Start the worker, unless it runs. + fn start(&self, mut st: MutexGuard<'_, State>, wake: &Waker) { + if !st.worker { + st.worker = true; + drop(st); + self.spawn_worker(wake.clone()); + } + } + fn spawn_worker(&self, wake: Waker) { let (state, probe) = (self.state.clone(), self.probe.clone()); let started = std::thread::Builder::new().name("lc-availability".into()).spawn(move || { loop { - let path = { + // what was asked first; a sweep's next file when nothing is (unless it is asked + // about too, or was checked a moment ago) + let (path, asked) = { let mut st = lock(&state); - let Some(p) = st.queue.pop_front() else { - st.worker = false; - return; - }; - p + let giving_way = if st.queue.is_empty() && !st.sweep.is_empty() { st.busy.clone() } else { None }; + if let Some(busy) = giving_way { + drop(st); + if busy() { + // (the worker stays: a question asked meanwhile is answered within YIELD_POLL) + std::thread::sleep(YIELD_POLL); + continue; + } + st = lock(&state); + } + match st.queue.pop_front() { + Some(p) => (p, true), + None => loop { + let Some(p) = st.sweep.pop_front() else { + st.worker = false; + return; + }; + let fresh = st.known.get(&p).is_some_and(|(_, at)| at.elapsed() < RECHECK); + if !fresh && !st.queued.contains(&p) { + break (p, false); + } + }, + } }; let ok = probe(&path); - let changed = { + let wakes = { let mut st = lock(&state); - st.queued.remove(&path); + if asked { + st.queued.remove(&path); + } + let gone_changed = if ok { st.gone.remove(&path) } else { st.gone.insert(path.clone()) }; let prev = st.known.insert(path, (ok, std::time::Instant::now())); let changed = prev.map(|p| p.0) != Some(ok); - if changed { - st.generation += 1; + if gone_changed { + st.gone_generation += 1; } - changed + (asked && changed) || gone_changed }; - if changed { + if wakes { wake(); } } }); if started.is_err() { - // try again on the next question - lock(&self.state).worker = false; + // try again on the next question; a sweep nobody works on would refuse every new one + let mut st = lock(&self.state); + st.worker = false; + st.sweep.clear(); } } } @@ -210,6 +326,209 @@ mod tests { assert!(a.is_offline("gone") && !a.is_offline("here") && !a.is_offline("unknown")); assert_eq!(on_caller.load(Ordering::SeqCst), 0, "never probed on the calling thread"); assert!(woke.load(Ordering::SeqCst) >= 2); - assert!(a.generation() >= 2); + assert!(a.gone_generation() >= 2); + } + + fn wait_until(what: &str, done: impl Fn() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !done() { + assert!(std::time::Instant::now() < deadline, "timed out waiting: {what}"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn held(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A background `Availability` whose probe logs what it checks; files named `gone…` are not there. + fn logged() -> (Availability, Arc>>, Arc) { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::new())); + let l = log.clone(); + a.set_probe(Arc::new(move |p| { + held(&l).push(p.to_string()); + !p.starts_with("gone") + })); + let woke = Arc::new(AtomicUsize::new(0)); + let w = woke.clone(); + a.run_in_background(Arc::new(move || { + w.fetch_add(1, Ordering::SeqCst); + })); + (a, log, woke) + } + + #[test] + fn the_files_a_sweep_finds_gone_are_counted() { + let (a, _, _) = logged(); + assert!(a.sweep(vec!["here".into(), "gone 1".into(), "also here".into(), "gone 2".into()])); + wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0); + wait_until("the answers", || a.gone_len() == 2); + assert!(a.is_gone("gone 1") && a.is_gone("gone 2") && !a.is_gone("here") && !a.is_gone("also here")); + } + + #[test] + fn files_found_there_at_first_look_change_nothing_for_missing_photos() { + let (a, _, woke) = logged(); + let before = a.gone_generation(); + a.sweep(vec!["a".into(), "b".into(), "c".into()]); + wait_until("the answers", || ["a", "b", "c"].iter().all(|p| lock(&a.state).known.contains_key(*p))); + assert_eq!(a.gone_generation(), before, "nothing went missing"); + assert_eq!(woke.load(Ordering::SeqCst), 0, "a sweep finding files there wakes nobody"); + a.sweep(vec!["gone".into()]); + wait_until("the gone file", || a.gone_generation() != before); + wait_until("the wake", || woke.load(Ordering::SeqCst) == 1); + } + + #[test] + fn what_is_asked_is_checked_before_the_rest_of_a_sweep_and_one_sweep_runs_at_a_time() { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::::new())); + // the first file of the sweep holds the worker until the test has asked about another + let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let (l, g) = (log.clone(), gate.clone()); + a.set_probe(Arc::new(move |p| { + if p == "sweep 0" { + let (open, cv) = &*g; + let mut open = held(open); + while !*open { + open = cv.wait(open).unwrap_or_else(PoisonError::into_inner); + } + } + held(&l).push(p.to_string()); + true + })); + a.run_in_background(Arc::new(|| {})); + assert!(a.sweep((0..50).map(|i| format!("sweep {i}")).collect())); + assert!(!a.sweep(vec!["another".into()]), "a sweep under way: the next one waits for its turn"); + assert_eq!(a.exists("asked"), None, "not known yet"); + *held(&gate.0) = true; + gate.1.notify_all(); + wait_until("the log", || held(&log).len() == 51); + let log = held(&log); + // (at most the file the worker was already checking comes first) + assert!(matches!(log.iter().position(|p| p == "asked"), Some(0 | 1)), "asked next, ahead of the sweep: {log:?}"); + assert!(!log.iter().any(|p| p == "another")); + } + + #[test] + fn a_new_sweep_replaces_the_one_under_way_and_a_list_made_for_the_old_one_is_refused() { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::::new())); + // the old sweep's first file holds the worker until the new one is in place + let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let (l, g) = (log.clone(), gate.clone()); + a.set_probe(Arc::new(move |p| { + if p == "old 0" { + let (open, cv) = &*g; + let mut open = held(open); + while !*open { + open = cv.wait(open).unwrap_or_else(PoisonError::into_inner); + } + } + held(&l).push(p.to_string()); + true + })); + a.run_in_background(Arc::new(|| {})); + let old = a.restart_sweeps(); + assert!(a.sweep_in(old, (0..20).map(|i| format!("old {i}")).collect())); + wait_until("the worker takes the first file", || a.sweeping() == 19); + // another library is opened: what is left of the old sweep goes, and a list still being made for it too + let new = a.restart_sweeps(); + assert_eq!(a.sweeping(), 0); + assert!(!a.sweep_in(old, vec!["old late".into()]), "a list made for the old library is refused"); + assert!(a.sweep_in(new, (0..3).map(|i| format!("new {i}")).collect())); + *held(&gate.0) = true; + gate.1.notify_all(); + wait_until("the new sweep", || held(&log).len() == 4); + assert_eq!(*held(&log), ["old 0", "new 0", "new 1", "new 2"]); + } + + #[test] + fn files_found_gone_are_told_apart_from_files_never_checked() { + let (a, _, _) = logged(); + assert!(!a.is_gone("gone 1") && a.gone_len() == 0); + assert!(a.sweep(vec!["gone 1".into(), "here".into()])); + wait_until("the sweep", || a.sweeping() == 0 && a.pending() == 0 && a.gone_len() == 1); + assert!(a.is_gone("gone 1") && !a.is_gone("here") && !a.is_gone("never asked")); + } + + fn busy_flag(a: &Availability) -> Arc { + let busy = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let b = busy.clone(); + a.sweep_yields_to(Arc::new(move || b.load(Ordering::SeqCst))); + busy + } + + #[test] + fn a_sweep_gives_way_to_file_work_answers_what_is_asked_and_resumes_after() { + let (a, log, _) = logged(); + let busy = busy_flag(&a); + assert!(a.sweep((0..20).map(|i| format!("sweep {i}")).collect())); + std::thread::sleep(YIELD_POLL * 3); + assert!(held(&log).is_empty(), "no sweep check while files are busy"); + assert_eq!(a.sweeping(), 20, "nothing of the sweep is lost"); + assert_eq!(a.exists("asked"), None); + wait_until("the answer", || a.exists("asked") == Some(true)); + assert_eq!(*held(&log), ["asked"], "what is asked is answered, the sweep still waits"); + busy.store(false, Ordering::SeqCst); + wait_until("the sweep", || a.sweeping() == 0); + wait_until("the log", || held(&log).len() == 21); + // it went on from its first file, not from scratch + assert_eq!(held(&log).iter().filter(|p| *p == "sweep 0").count(), 1); + } + + #[test] + fn a_sweep_stopped_part_way_goes_on_from_there() { + let mut a = Availability::default(); + let log = Arc::new(Mutex::new(Vec::::new())); + let l = log.clone(); + // the worker is held inside the fourth check until the sweep is told to give way + let hold = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let h = hold.clone(); + a.set_probe(Arc::new(move |p| { + while p == "sweep 3" && h.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(1)); + } + held(&l).push(p.to_string()); + true + })); + a.run_in_background(Arc::new(|| {})); + let busy = busy_flag(&a); + busy.store(false, Ordering::SeqCst); + assert!(a.sweep((0..100).map(|i| format!("sweep {i}")).collect())); + wait_until("a few checks", || held(&log).len() >= 3); + busy.store(true, Ordering::SeqCst); + hold.store(false, Ordering::SeqCst); + std::thread::sleep(YIELD_POLL * 2); + let stopped = held(&log).len(); + assert_eq!(stopped, 4, "stopped part way, after the check it was in"); + std::thread::sleep(YIELD_POLL * 2); + assert_eq!(held(&log).len(), stopped, "paused"); + assert_eq!(a.sweeping(), 100 - stopped, "what is left is kept"); + busy.store(false, Ordering::SeqCst); + wait_until("the sweep", || a.sweeping() == 0 && held(&log).len() == 100); + let mut all = held(&log).clone(); + all.sort(); + all.dedup(); + assert_eq!(all.len(), 100, "every file checked once, none again from the start"); + } + + #[test] + fn a_sweep_that_yields_to_nothing_runs_as_before() { + let (a, log, _) = logged(); + assert!(a.sweep(vec!["a".into(), "b".into()])); + wait_until("the sweep", || held(&log).len() == 2); + } + + #[test] + fn a_sweep_skips_files_checked_a_moment_ago() { + let (a, log, _) = logged(); + assert_eq!(a.exists("a"), None); + wait_until("the answer", || a.exists("a").is_some()); + a.sweep(vec!["a".into(), "b".into()]); + wait_until("b", || held(&log).iter().any(|p| p == "b")); + wait_until("the sweep", || a.sweeping() == 0); + assert_eq!(held(&log).iter().filter(|p| *p == "a").count(), 1, "`a` was checked a moment ago"); } } diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index f6600c6b1..383ed890d 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -9,10 +9,12 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, cmd}; use crate::{Result, Session}; -/// Seconds between shots of the same burst at most. -const BURST_GAP: i64 = 10; -/// Signature similarity for "the same scene". -const SAME: f32 = 0.93; +/// Seconds between shots of the same burst at most (default of `burstGap`). +const BURST_GAP: f64 = 10.0; +/// Signature similarity for "the same scene" (default of `similarity`). +const SAME: f64 = 0.93; +/// The longest `burstGap` accepted: a day. +const MAX_GAP: f64 = 86_400.0; fn secs(iso: &str) -> Option { let d = lightcraft_meta::DateTime::parse_iso(iso)?; @@ -26,6 +28,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result { let ids: Vec = ids.into_iter().filter(|id| seen.insert(*id)).collect(); let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); + let (gap, same) = burst_params(p)?; // measure (thumbnail-level sources: fast, and enough for focus at 512 px) let mut rows: Vec<(PhotoId, Option, f32, f32, [f32; 64])> = Vec::new(); let mut failed = Vec::new(); @@ -45,8 +48,8 @@ fn analyze(s: &mut Session, p: &Value) -> Result { let mut next = 1u32; for i in 1..rows.len() { let (a, b) = (&rows[i - 1], &rows[i]); - let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y - x).abs() <= BURST_GAP); - if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= SAME { + let close = matches!((a.1, b.1), (Some(x), Some(y)) if (y.saturating_sub(x)).unsigned_abs() as f64 <= gap); + if close && lightcraft_pipeline::cull::similarity(&a.4, &b.4) >= same { let g = *group_of[i - 1].get_or_insert_with(|| { next += 1; next - 1 @@ -86,6 +89,22 @@ fn analyze(s: &mut Session, p: &Value) -> Result { Ok(json!({"photos": out, "groups": best.len(), "rejected": rejected, "picked": picked, "failed": failed})) } +/// `burstGap` (seconds, 0..=a day) and `similarity` (0..=1) of `photo.analyze`, refused before +/// anything is measured when they are not numbers in range. +fn burst_params(p: &Value) -> Result<(f64, f32)> { + const C: &str = "photo.analyze"; + let num = |key: &str, d: f64, max: f64| -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(d), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (0.0..=max).contains(&x) => Ok(x), + _ => Err(super::bad(C, format!("`{key}` must be a number from 0 to {max}"))), + }, + } + }; + Ok((num("burstGap", BURST_GAP, MAX_GAP)?, num("similarity", SAME, 1.0)? as f32)) +} + /// Find Similar: photos that look like `id` (signatures from thumbnail-level sources, cached by /// content), most similar first; the view is filtered to them. fn find_similar(s: &mut Session, p: &Value) -> Result { @@ -139,7 +158,7 @@ pub fn specs() -> Vec { "Assisted Culling", [], None, - "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots taken within 10 s and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", + "{ids?, rejectBelow?: sharpness 0..100, pickBest?: bool, burstGap?: seconds 0..86400 (10), similarity?: 0..1 (0.93)} — score the selected photos (else all in view) for focus (0..100) and clipping, group look-alike shots (signature similarity at least `similarity`) taken at most `burstGap` seconds apart and mark the sharpest of each; optionally reject blurry photos and pick each group's best; one undo step → {photos: [{id, sharpness, clipped, group, best}], groups, rejected, picked}", always, analyze ), diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 0f3b8827a..07dcbe0ec 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -451,7 +451,10 @@ pub fn specs() -> Vec { let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable}); match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) { Some(path) => { - std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + s.check_write_target(path).map_err(|e| bad("keyword.export", e))?; + // A folder that isn't there is an error, not created. + lightcraft_catalog::safe_file::write_atomic(std::path::Path::new(path), file.text.as_bytes()) + .map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; r["path"] = json!(path); } None => r["text"] = json!(file.text), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 334d9c92d..b019e21fd 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -7,6 +7,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; +/// The selection after photos left the view (deleted, or rated, flagged or labelled out of it). +/// Its callers end an open edit (a slider drag) before their own change, as moving to the next +/// photo does, so it is its own undo step and comes first. fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { let at = before.iter().position(|id| Some(*id) == s.selection.active); let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); @@ -201,7 +204,6 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result 0 { s.commit(label, Op::Batch { ops })?; } - advance_if(s, p); Ok(json!({"changed": n})) } @@ -220,10 +222,36 @@ fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> F if n > 0 { s.commit(label, Op::Batch { ops })?; } - advance_if(s, p); Ok(json!({"changed": n})) } +/// Rate, flag or label (`edit`), then `advance`. When the edit took the active photo out of the view (the filter bar, +/// Picks or a smart album shows it no more), culling goes on from its place, as after a delete: the photo after it is +/// active, or the one before it at the end (#2312), not the view's first photo. That photo is the next one, so +/// `advance` doesn't step past it. Photos named by `ids` / `id` leave the selection to the caller: Compare and Survey +/// keep showing the photo they rate. +fn cull(s: &mut Session, p: &Value, edit: impl FnOnce(&mut Session) -> Result) -> Result { + let named = [p.get("ids"), p.get("id")].into_iter().any(|t| t.is_some_and(|t| !t.is_null())); + // the view as it was, then an open drag ends first, as its own undo step (the selection may + // move on; the drag itself can take the photo out of the view) + let before = (!named).then(|| s.visible_shared().1); + if !named { + s.end_interaction()?; + } + let active = s.selection.active; + let v = edit(s)?; + if let Some(before) = before + && active.is_some_and(|a| before.contains(&a) && !s.visible().contains(&a)) + { + select_after_deletion(s, &before); + s.active_mask = None; + s.active_spot = None; + } else { + advance_if(s, p); + } + Ok(v) +} + fn advance_if(s: &mut Session, p: &Value) { if bool_or(p, "advance", false) { let _ = step(s, 1); @@ -764,40 +792,66 @@ pub fn specs() -> Vec { |s, p| { step(s, -row_step(p)) } ), // ---- rating / flags / labels - cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| { - let r = super::f64_req(p, "rating", "photo.rate")? as i64; - if !(0..=5).contains(&r) { - return Err(bad("photo.rate", "rating must be 0..5")); + cmd!( + "photo.rate", + "Set Rating", + ["Photo", "Set Rating"], + None, + "{rating: 0..5, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let r = super::f64_req(p, "rating", "photo.rate")? as i64; + if !(0..=5).contains(&r) { + return Err(bad("photo.rate", "rating must be 0..5")); + } + cull(s, p, |s| for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))) } - let v = for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))?; - advance_if(s, p); - Ok(v) - }), - cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| { - change_rating(s, p, -1, "Decrease Rating") - }), - cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| { - change_rating(s, p, 1, "Increase Rating") - }), - cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| { - let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; - let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?; - advance_if(s, p); - Ok(v) - }), + ), + cmd!( + "photo.decreaseRating", + "Decrease Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, -1, "Decrease Rating")) } + ), + cmd!( + "photo.increaseRating", + "Increase Rating", + [], + None, + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| change_rating(s, p, 1, "Increase Rating")) } + ), + cmd!( + "photo.flag", + "Set Flag", + ["Photo", "Set Flag"], + None, + "{flag: pick|reject|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { + let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; + cull(s, p, |s| for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))) + } + ), // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) cmd!( "photo.cycleFlag", "Cycle Flag", ["Photo", "Set Flag"], Some("`"), - "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged", + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged; without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, |s, p| { - change_flag(s, p, "Cycle Flag", |f| match f { - Flag::None => Flag::Pick, - Flag::Pick => Flag::Reject, - Flag::Reject => Flag::None, + cull(s, p, |s| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) }) } ), @@ -806,34 +860,52 @@ pub fn specs() -> Vec { "Increase Flag Status", ["Photo", "Set Flag"], Some("Cmd+Up"), - "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)", + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick); without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, - |s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) } + |s, p| { cull(s, p, |s| change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick })) } ), cmd!( "photo.decreaseFlag", "Decrease Flag Status", ["Photo", "Set Flag"], Some("Cmd+Down"), - "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)", + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject); without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, - |s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) } + |s, p| { cull(s, p, |s| change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject })) } + ), + cmd!( + "photo.pick", + "Flag as Pick", + ["Photo", "Set Flag"], + Some("P"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))) } + ), + cmd!( + "photo.reject", + "Flag as Reject", + ["Photo", "Set Flag"], + Some("X"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))) } + ), + cmd!( + "photo.unflag", + "Unflag", + ["Photo", "Set Flag"], + Some("U"), + "{ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", + has_selection, + |s, p| { cull(s, p, |s| for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))) } ), - cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| { - for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick })) - }), - cmd!("photo.reject", "Flag as Reject", ["Photo", "Set Flag"], Some("X"), "{ids?}", has_selection, |s, p| { - for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject })) - }), - cmd!("photo.unflag", "Unflag", ["Photo", "Set Flag"], Some("U"), "{ids?}", has_selection, |s, p| { - for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None })) - }), cmd!( "photo.label", "Set Color Label", ["Photo", "Set Color Label"], None, - "{label: red|yellow|green|blue|purple|none, ids?}", + "{label: red|yellow|green|blue|purple|none, ids?, advance?: bool} — without `ids`, the selection moves on when this takes the active photo out of the view", has_selection, |s, p| { // `none` (or null) clears; a missing label or one that isn't text is an error, not a clear @@ -844,7 +916,7 @@ pub fn specs() -> Vec { None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")), Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")), }; - for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l })) + cull(s, p, |s| for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))) } ), // ---- orientation @@ -915,6 +987,8 @@ pub fn specs() -> Vec { cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let before = s.visible_cloned(); let targets = s.targets(p)?; + // an open drag ends first, as its own undo step (the selection moves on) + s.end_interaction()?; let n = mark_deleted(s, &targets, "Delete")?; select_after_deletion(s, &before); Ok(json!({"changed": n})) @@ -944,6 +1018,8 @@ pub fn specs() -> Vec { cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let before = s.visible_cloned(); let t = s.targets(p)?; + // before the inverse ops are taken, so undo brings the photo back with its edit + s.end_interaction()?; let op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; select_after_deletion(s, &before); @@ -1635,7 +1711,7 @@ pub fn specs() -> Vec { s.compact_library()?; ok() }), - cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| { + cmd!("library.clearPreviews", "Discard Preview Cache", ["File"], None, "{}", always, |s, _| { s.media.rendered.clear(); s.media.clear_sources(); ok() @@ -1804,6 +1880,10 @@ mod relative_rating_tests; #[path = "library/tests_flag_cycle.rs"] mod flag_cycle_tests; +#[cfg(test)] +#[path = "library/tests_rated_out.rs"] +mod rated_out_tests; + #[cfg(test)] #[path = "library/tests_enable_filters.rs"] mod enable_filters_tests; diff --git a/crates/engine/src/cmd/library/tests_rated_out.rs b/crates/engine/src/cmd/library/tests_rated_out.rs new file mode 100644 index 000000000..49c36596b --- /dev/null +++ b/crates/engine/src/cmd/library/tests_rated_out.rs @@ -0,0 +1,139 @@ +//! A rating, flag or label that takes the active photo out of the view (the filter bar, Picks) goes on from its place: +//! the photo that took it, not the view's first photo (#2312). +use lightcraft_catalog::{ColorLabel, Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +/// 100 photos with one star each, in file-name order (photo `n` is the `n`th), the filter showing one-star photos. +fn one_star() -> Result { + let mut s = Session::new(); + for id in 1..=100 { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-rated-out/{id:03}.jpg") }, + &format!("{id:03}.jpg"), + "JPEG", + 40, + 30, + "2026-10-19", + ); + photo.rating = 1; + photo.flag = Flag::Pick; + photo.label = Some(ColorLabel::Red); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"}))?; + Ok(s) +} + +#[test] +fn a_star_taken_off_goes_on_to_the_next_photo() { + // Given photo 82 of 100 one-star photos, the filter showing one-star photos + let mut s = one_star().unwrap(); + s.execute("library.select", &json!({"ids": [82]})).unwrap(); + // When its star is taken off, Then it leaves the view and the photo after it is the one selected + s.execute("photo.rate", &json!({"rating": 0})).unwrap(); + assert!(!s.visible_cloned().contains(&PhotoId(82))); + assert_eq!(s.selection, Selection::single(PhotoId(83))); + // and the next photo is the one after that, not the first one of the view + s.execute("library.next", &json!({})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(84))); + // With `advance`, the photo that took its place is the next one: no photo is skipped + s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(85))); + s.execute("photo.decreaseRating", &json!({"advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(86))); + // The last photo of the view hands on to the one before it + s.execute("library.select", &json!({"ids": [100]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(99))); + // A rating that keeps the photo in the view advances as before + s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(99)), "the last photo of the view stays"); + s.execute("library.select", &json!({"ids": [10]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 1, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(11))); +} + +#[test] +fn flags_and_labels_that_leave_the_view_go_on_to_the_next_photo_too() { + for (filter, command, params) in [ + (json!({"flag": "pick"}), "photo.reject", json!({})), + (json!({"flag": "pick"}), "photo.flag", json!({"flag": "none"})), + (json!({"flag": "pick"}), "photo.cycleFlag", json!({})), + (json!({"flag": "pick"}), "photo.decreaseFlag", json!({})), + (json!({"label": "red"}), "photo.label", json!({"label": "none"})), + (json!({}), "photo.increaseRating", json!({})), + ] { + let mut s = one_star().unwrap(); + s.execute("library.filter", &filter).unwrap(); + s.execute("library.select", &json!({"ids": [40]})).unwrap(); + s.execute(command, ¶ms).unwrap(); + assert!(!s.visible_cloned().contains(&PhotoId(40)), "{command} {params}"); + assert_eq!(s.selection, Selection::single(PhotoId(41)), "{command} {params}"); + } +} + +#[test] +fn several_photos_rated_out_go_on_after_the_active_one() { + let mut s = one_star().unwrap(); + // three selected, the middle one active: all leave, the photo after the active one is selected + s.execute("library.select", &json!({"ids": [20, 21, 22], "active": 21})).unwrap(); + s.execute("photo.rate", &json!({"rating": 0})).unwrap(); + assert_eq!(s.selection, Selection::single(PhotoId(23))); + // the active one leaves and another selected photo stays: that one is active + s.execute("photo.rate", &json!({"ids": [30], "rating": 3})).unwrap(); + s.execute("library.filter", &json!({"rating": 1, "ratingOp": "atLeast"})).unwrap(); + s.execute("library.select", &json!({"ids": [29, 30], "active": 29})).unwrap(); + s.execute("photo.decreaseRating", &json!({})).unwrap(); + assert_eq!(s.selection, Selection { ids: vec![PhotoId(30)], active: Some(PhotoId(30)) }); +} + +#[test] +fn named_photos_and_a_held_view_keep_the_selection() { + // Photos named by `ids` leave the selection to the caller (Compare and Survey keep the photo they show) + let mut s = one_star().unwrap(); + s.execute("library.select", &json!({"ids": [5, 6], "active": 6})).unwrap(); + s.execute("photo.rate", &json!({"ids": [6], "rating": 0})).unwrap(); + assert_eq!(s.selection, Selection { ids: vec![PhotoId(5), PhotoId(6)], active: Some(PhotoId(6)) }); + // A held view keeps the photo, so the selection stays with it + let mut s = one_star().unwrap(); + s.execute("library.holdView", &json!({"on": true})).unwrap(); + // (what the grid shows is what is held) + assert_eq!(s.visible_cloned().len(), 100); + s.execute("library.select", &json!({"ids": [7]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 0})).unwrap(); + assert!(s.visible_cloned().contains(&PhotoId(7))); + assert_eq!(s.selection, Selection::single(PhotoId(7))); + s.execute("photo.rate", &json!({"rating": 0, "advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(8))); +} + +/// A slider drag still open when a rating takes the photo out of the view, or when the photo is +/// deleted, ends as its own undo step before that change, as moving to the next photo does: undo +/// brings the rating or the photo back first, then the edit. Before, the move left the drag open +/// with no undo step (CodeRabbit on #2689). +#[test] +fn an_open_edit_ends_before_the_selection_moves_on() { + let exposure = |s: &Session, id| s.develop_of(PhotoId(id)).map(|d| d.light.exposure); + for (command, params) in [("photo.rate", json!({"rating": 0})), ("photo.delete", json!({})), ("photo.deletePermanently", json!({}))] { + let mut s = one_star().unwrap(); + s.execute("library.select", &json!({"ids": [82]})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap(); + s.execute(command, ¶ms).unwrap(); + assert_eq!(s.active(), Some(PhotoId(83)), "{command}"); + assert!(s.interaction.is_none(), "{command}: the drag ended with the move"); + if command != "photo.deletePermanently" { + assert_eq!(exposure(&s, 82), Some(1.5), "{command}"); + } + // undo: the rating or the photo comes back first, with the edit; then the edit goes + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.visible_cloned().contains(&PhotoId(82)), "{command}: back in the view"); + assert_eq!(exposure(&s, 82), Some(1.5), "{command}: with its edit"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(exposure(&s, 82), Some(0.0), "{command}"); + } +} diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 339b20b90..9b1f7e9f7 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Edit Capture Time", [], None, - "{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}", + "{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..], unreadable: photos skipped because their capture time can't be read}", has_selection, |s, p| { use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; @@ -213,12 +213,25 @@ pub fn specs() -> Vec { let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or("")) .and_then(|_| iso_seconds(&anchor_date)) .ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; - delta += iso_seconds(&t).unwrap_or(from) - from; + // the others move by the anchor's change, so it must read in full, not just + // its leading digits (a Lightroom catalog's capture time is copied verbatim); + // alone, it simply gets the time, which repairs it + if shift_iso(&anchor_date, 0).is_some() { + delta += iso_seconds(&t).unwrap_or(from) - from; + } else if targets.len() == 1 { + each = Some(t); + } else { + return Err(bad( + c, + "the active photo's capture time can't be read, so the other photos can't move by the same amount; set its time with it selected alone", + )); + } } } let mut ops = Vec::new(); let mut out = Vec::new(); let mut out_of_range = 0usize; + let mut unreadable = 0usize; for id in &targets { let from = match &each { Some(t) => t.clone(), @@ -226,9 +239,11 @@ pub fn specs() -> Vec { }; let Some(new) = shift_iso(&from, delta) else { // a date that reads but would leave years 0000–9999 is refused below; one - // that doesn't read is skipped, as before - if iso_seconds(&from).is_some() { + // that doesn't read is skipped and counted + if shift_iso(&from, 0).is_some() { out_of_range += 1; + } else { + unreadable += 1; } continue; }; @@ -244,7 +259,7 @@ pub fn specs() -> Vec { if n > 0 { s.commit("Edit Capture Time", Op::Batch { ops })?; } - Ok(json!({"changed": n, "captured": out})) + Ok(json!({"changed": n, "captured": out, "unreadable": unreadable})) } ), cmd!( diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index dfbf4bfc5..a3926bc51 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -1,6 +1,8 @@ //! Read-only queries (not journaled): catalog, photos, develop state, controls, presets, albums. -use lightcraft_catalog::{Album, Photo, PhotoId}; +use std::collections::HashMap; + +use lightcraft_catalog::{Album, AlbumId, Photo, PhotoId}; use lightcraft_develop::{CONTROLS, controls}; use serde_json::{Value, json}; @@ -36,9 +38,9 @@ pub fn photo_summary(p: &Photo) -> Value { /// instead of overflowing the stack. const MAX_ALBUM_DEPTH: usize = 64; -fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value { +fn album_json(a: &Album, all: &[Album], counts: &HashMap, cat: &lightcraft_catalog::Catalog, depth: usize) -> Value { let children: Vec = if depth < MAX_ALBUM_DEPTH { - all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect() + all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, counts, cat, depth + 1)).collect() } else { Vec::new() }; @@ -46,7 +48,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth "id": a.id.0, "name": a.name, "folder": a.folder, - "count": cat.album_count(a.id), + "count": counts.get(&a.id).copied().unwrap_or(0), "cover": a.cover.map(|c| c.0), "children": children, }); @@ -152,7 +154,9 @@ pub fn specs() -> Vec { }), cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| { let all: Vec = s.catalog.albums().cloned().collect(); - Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect())) + // every album counted in one pass, so a smart album tested by others is worked out once + let counts = lightcraft_catalog::tally::AlbumTally::of(&s.catalog); + Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, counts.counts(), &s.catalog, 0)).collect())) }), cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| { let id = photo_arg(s, p, "photo.inspect")?; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 431eec763..38a42fe95 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -31,7 +31,7 @@ pub fn load(s: &mut Session) { ..Default::default() }; // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so - // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + // View ▸ Focus Point and Zoom to Focus Point can be tried without a camera file if sc.name == "Cosmos bloom" { p.meta.focus = Some(lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 0e2254afd..b1a5bce0f 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -501,11 +501,12 @@ pub(crate) fn watermark_logo(path: &str) -> Option> { } /// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the -/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded. +/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it. +/// The error says why it couldn't be read or decoded. pub fn read_graphic(path: &str) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; - Ok(decoded.to_srgb8()) + Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))) } /// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to @@ -518,20 +519,27 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { return; } - let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width); - let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height); + let turns = wm.quarter_turns(); + // Reserve the inset on anchored axes before fitting the turned box. Centered axes + // ignore insets, just as placement below does. One scale preserves the graphic's shape. + let short = width.min(height) as f32; + let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); + use Anchor::*; + let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 }; + let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 }; + let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) }; + let scale = + (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64); + let lw = (logo.width as f64 * scale).round().max(1.0) as usize; + let lh = (logo.height as f64 * scale).round().max(1.0) as usize; let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { let p = logo.get(x, y); let a = p[3] as f32 / 255.0; [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] }); let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); - let short = width.min(height) as f32; - let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - let turns = wm.quarter_turns(); let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); - use Anchor::*; let ox = match wm.anchor { TopLeft | Left | BottomLeft => ix, Top | Center | Bottom => (w - lwf) / 2.0, @@ -658,8 +666,8 @@ pub struct ExportOptions { /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. pub bit_depth: Option, /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR - /// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb` - /// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers + /// rendition plus a gain map, so the file looks right everywhere; `limit_kb` includes both + /// images and their metadata), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers /// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float /// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR /// edit, are SDR. @@ -1392,29 +1400,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta); - match o.limit_kb { - Some(kb) => { - let limit = kb as usize * 1024; - // Binary search for the highest quality that fits. - let (mut lo, mut hi) = (1u8, o.quality); - let mut best = None; - while lo <= hi { - let mid = lo + (hi - lo) / 2; - let b = jpeg(mid).map_err(|e| e.to_string())?; - if b.len() <= limit { - best = Some(b); - lo = mid + 1; - } else if mid == 1 { - break; - } else { - hi = mid - 1; - } - } - return best.ok_or_else(|| format!("cannot fit within {kb} KB")); - } - None => jpeg(o.quality), - } + return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), @@ -1428,6 +1414,27 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. +fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result>) -> Result, String> { + let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; + let limit = u64::from(kb) * 1024; + let (mut lo, mut hi) = (1u8, o.quality.min(100)); + let mut best = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let b = jpeg(mid).map_err(|e| e.to_string())?; + if b.len() as u64 <= limit { + best = Some(b); + lo = mid + 1; + } else if mid == 1 { + break; + } else { + hi = mid - 1; + } + } + best.ok_or_else(|| format!("cannot fit within {kb} KB")) +} + /// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { let mut img = img.clone(); @@ -1475,7 +1482,7 @@ pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, met let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string()) + encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) } /// The codecs' name of an output space (for its ICC profile). @@ -2429,6 +2436,139 @@ mod tests { assert!(b.len() <= kb as usize * 1024); } + #[test] + fn hdr_jpeg_size_limit_respected() { + let img = Rgba8::from_fn(256, 192, |x, y| { + let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); + [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] + }); + let hdr = DeepImage { + width: img.width, + height: img.height, + space: OutputSpace::Srgb, + samples: DeepSamples::F32( + img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), + ), + }; + let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; + let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); + let kb = (full.len() / 2048) as u32; + let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); + assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); + let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); + assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); + let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); + assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); + } + + #[test] + fn graphic_watermark_honours_exif_orientation() { + let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); + for orientation in [2, 6] { + let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&meta); + let bytes = lightcraft_codecs::encode_jpeg( + &EncodeImage::rgba8(&logo), + 100, + ChromaSubsampling::S444, + &EncodeMeta { exif: Some(&exif), ..Default::default() }, + ) + .unwrap(); + let path = dir.join(format!("oriented-{orientation}.jpg")); + std::fs::write(&path, bytes).unwrap(); + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: if orientation == 6 { 0.25 } else { 0.5 }, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; + let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + if orientation == 6 { + assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); + } else { + assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { + let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + rotation, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, + lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, + ); + assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn graphic_watermark_fit_preserves_anchor_insets() { + let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + use Anchor::*; + for (anchor, rotation, expected) in [ + (BottomRight, 90, (0, 44, 111, 71)), + (TopLeft, 270, (8, 8, 119, 35)), + (Right, 90, (0, 26, 111, 53)), + (BottomRight, 0, (94, 0, 111, 71)), + (TopLeft, 180, (8, 8, 25, 79)), + (Bottom, 0, (51, 0, 68, 71)), + // Centered axes ignore their inset rather than shrinking the image unnecessarily. + (Center, 90, (0, 25, 119, 54)), + (Top, 90, (0, 8, 119, 37)), + ] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.1, + inset_y: 0.1, + rotation, + anchor, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).min().unwrap(), + lit.iter().map(|p| p.1).min().unwrap(), + lit.iter().map(|p| p.0).max().unwrap(), + lit.iter().map(|p| p.1).max().unwrap(), + ); + assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn sharpen_increases_edge_contrast() { let mut img = Rgba8::new(8, 8); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 62bd33820..bb81bd240 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -374,6 +374,42 @@ pub fn is_supported(path: &Path) -> bool { path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) } +/// The web file picker's accept list, derived from [`EXTENSIONS`]. Each extension +/// is listed in both cases: browsers' `accept` matching is case-insensitive per +/// spec, but some implementations (e.g. Windows/Edge) match case-sensitively and +/// rejected uppercase `.NEF` files, so a lowercase-only list silently hid raws +/// such as CR3, NRW, RAF, ORF, RW2, RWL, RAW, PEF and SRW from the picker. +/// +/// The picker only offers files this build can actually import: `.avif` has no +/// AV1 decoder in any build, and `.heic`/`.heif` decode only when the codecs' +/// `heif` feature is enabled ([`lightcraft_codecs::Format::Heif::can_decode`]). +/// [`EXTENSIONS`] itself stays complete — files on disk are still recognised, +/// and import reports them as failed, saying why. +pub fn accept_list() -> String { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + let mut out = String::new(); + for &ext in EXTENSIONS { + // Skip extensions a web user could pick but this build could not import. + let pickable = match ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }; + if !pickable { + continue; + } + if !out.is_empty() { + out.push(','); + } + out.push('.'); + out.push_str(ext); + out.push(','); + out.push('.'); + out.push_str(&ext.to_uppercase()); + } + out +} + /// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder) /// is never descended into. Each folder's walk is bounded ([`crate::walk::Limits::default`]); a /// walk that stops at a bound is logged. @@ -1607,6 +1643,52 @@ mod tests_known; mod prepared_tests { use super::*; + /// Extensions the picker may offer: every one in [`EXTENSIONS`], minus the + /// files this build cannot import (see [`accept_list`]). + fn pickable_extensions() -> Vec<&'static str> { + let heif_decodable = lightcraft_codecs::Format::Heif.can_decode(); + EXTENSIONS + .iter() + .copied() + .filter(|ext| match *ext { + "avif" => false, + "heic" | "heif" => heif_decodable, + _ => true, + }) + .collect() + } + + #[test] + fn accept_list_covers_every_pickable_extension_in_both_cases() { + let list = accept_list(); + for ext in pickable_extensions() { + assert!(list.contains(&format!(".{ext}")), "lowercase .{ext} missing from accept list"); + assert!(list.contains(&format!(".{}", ext.to_uppercase())), "uppercase .{} missing from accept list", ext.to_uppercase()); + } + } + + /// The web picker must not offer files this build cannot import: `.avif` has + /// no AV1 decoder in any build, so it is never listed, in either case. + #[test] + fn accept_list_never_offers_avif() { + let list = accept_list(); + assert!(!list.contains(".avif"), "{list}"); + assert!(!list.contains(".AVIF"), "{list}"); + } + + /// `.heic`/`.heif` follow the codecs' `heif` feature: listed (in both cases) + /// only when this build can decode them; otherwise hidden, so a web user + /// cannot pick a file that would fail to import. + #[test] + fn accept_list_offers_heic_heif_only_when_decodable() { + let list = accept_list(); + let listed = lightcraft_codecs::Format::Heif.can_decode(); + for ext in ["heic", "heif"] { + assert_eq!(list.contains(&format!(".{ext}")), listed, "lowercase .{ext}"); + assert_eq!(list.contains(&format!(".{}", ext.to_uppercase())), listed, "uppercase .{ext}"); + } + } + #[test] fn revalidate_add_only_converts_matching_ready_item() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ad5695dc..a74144e2d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1050,8 +1050,9 @@ impl Session { }; let view = format!("{}|{rules}", key.1); if self.source == LibrarySource::Missing && self.media.availability.is_background() { - // the view fills in as the background checks find files gone - key.1.push_str(&format!("|{}", self.media.availability.generation())); + // the view fills in as the background checks find files gone (a file found there at + // its first check changes nothing it shows) + key.1.push_str(&format!("|{}", self.media.availability.gone_generation())); } if self.visible_key.as_ref() != Some(&key) { // "in the last N days" rules count back from the session's clock diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs index 26d1818e5..be4438424 100644 --- a/crates/engine/src/print/layout.rs +++ b/crates/engine/src/print/layout.rs @@ -46,6 +46,41 @@ const PAPER: &[(&str, f64, f64)] = &[ ("5x7", 5.0, 7.0), ("8x10", 8.0, 10.0), ("13x19", 13.0, 19.0), + // Japanese photo printing and postcard sizes, at their exact metric dimensions (the US photo + // sizes above are close but not identical: L is 89 x 127 mm, 5x7 in is 127 x 177.8 mm). + ("l", 89.0 / 25.4, 127.0 / 25.4), + ("2l", 127.0 / 25.4, 178.0 / 25.4), + ("kg", 102.0 / 25.4, 152.0 / 25.4), + ("postcard", 100.0 / 25.4, 148.0 / 25.4), + ("double-postcard", 148.0 / 25.4, 200.0 / 25.4), + ("6cut", 203.0 / 25.4, 254.0 / 25.4), + ("4cut", 254.0 / 25.4, 305.0 / 25.4), + ("business-card", 55.0 / 25.4, 91.0 / 25.4), +]; + +/// Japanese customary names (and a few English spellings) for the papers in [`PAPER`], matched +/// after [`normalize_paper`]: `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on. +const PAPER_ALIASES: &[(&str, &str)] = &[ + ("L判", "l"), + ("Lサイズ", "l"), + ("L-size", "l"), + ("2L判", "2l"), + ("2Lサイズ", "2l"), + ("2L-size", "2l"), + ("KG判", "kg"), + ("KGサイズ", "kg"), + ("KG-size", "kg"), + ("はがき", "postcard"), + ("ハガキ", "postcard"), + ("葉書", "postcard"), + ("ポストカード", "postcard"), + ("往復はがき", "double-postcard"), + ("往復ハガキ", "double-postcard"), + ("六切", "6cut"), + ("6切", "6cut"), + ("四切", "4cut"), + ("4切", "4cut"), + ("名刺", "business-card"), ]; /// Names accepted by [`PageSetup::paper`] (besides `custom`). @@ -53,10 +88,39 @@ pub fn paper_names() -> impl Iterator { PAPER.iter().map(|p| p.0) } -/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`. +/// The form [`paper_name`] matches names in: lower case, letters and digits only, and `×`/`*` read +/// as `x`, so `L判`, `L判。`, `l-size` and `(l)` are one name and `4 × 6` is `4x6`. +fn normalize_paper(name: &str) -> String { + name.chars() + .filter_map(|c| match c { + '×' | '*' => Some('x'), + c if c.is_alphanumeric() => Some(c), + _ => None, + }) + .collect::() + .to_lowercase() +} + +/// The canonical name of the paper `name` stands for, `None` for unknown names and `custom`. +/// `L判` is `l`, `はがき` is `postcard`, `六切` is `6cut`, and so on. +pub fn paper_name(name: &str) -> Option<&'static str> { + let n = normalize_paper(name); + if n.is_empty() { + return None; + } + PAPER + .iter() + .find(|p| normalize_paper(p.0) == n) + .map(|p| p.0) + .or_else(|| PAPER_ALIASES.iter().find(|(alias, _)| normalize_paper(alias) == n).map(|(_, name)| *name)) +} + +/// Portrait size in inches of a named paper, `None` for unknown names and `custom`. Case, spaces +/// and punctuation don't matter, and the Japanese customary names (`L判`, `2L判`, `KG判`, `はがき`, +/// `往復はがき`, `六切`, `四切`, `名刺`) are aliases of the papers above. pub fn paper_size(name: &str) -> Option<(f64, f64)> { - let name = name.to_ascii_lowercase(); - PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2)) + let canonical = paper_name(name)?; + PAPER.iter().find(|p| p.0 == canonical).map(|p| (p.1, p.2)) } /// An axis-aligned rectangle in inches. @@ -935,6 +999,65 @@ mod tests { assert!(p.size().is_err()); } + /// Japanese photo and postcard papers: exact metric sizes to 0.1 pt, the customary names as + /// aliases, and the ISO/US papers around them unchanged. + #[test] + fn japanese_photo_and_postcard_papers() { + let pt = |name: &str| { + let p = PageSetup { paper: name.into(), ..Default::default() }; + p.size_points().unwrap_or_else(|e| panic!("{name}: {e}")) + }; + for (name, w_mm, h_mm) in [ + ("L判", 89.0, 127.0), + ("2L判", 127.0, 178.0), + ("KG判", 102.0, 152.0), + ("はがき", 100.0, 148.0), + ("往復はがき", 148.0, 200.0), + ("六切", 203.0, 254.0), + ("四切", 254.0, 305.0), + ("名刺", 55.0, 91.0), + ] { + let (w, h) = pt(name); + let want = (w_mm / 25.4 * 72.0, h_mm / 25.4 * 72.0); + assert!((w - want.0).abs() < 0.1 && (h - want.1).abs() < 0.1, "{name}: {w} x {h} pt, wanted {want:?}"); + assert!((w - h).abs() > 1e-6, "{name} is portrait"); + } + // The customary and English spellings resolve to the same paper, including spaces and case. + for (a, b) in [ + ("L判", "l"), + ("L判", " L "), + ("L判", "L-size"), + ("2L判", "2l"), + ("KG判", "kg"), + ("はがき", "postcard"), + ("はがき", "ハガキ"), + ("往復はがき", "double-postcard"), + ("六切", "6cut"), + ("六切", "6切"), + ("四切", "4cut"), + ("名刺", "business-card"), + ("A4", "a 4"), + ("4 × 6", "4x6"), + ] { + assert_eq!(paper_size(a), paper_size(b), "{a} vs {b}"); + } + // Punctuation is ignored, including the periods and Japanese marks a pasted name may carry. + for (name, canonical) in [("L判。", "l"), ("L判、", "l"), ("(L判)", "l"), ("l-size.", "l"), ("はがき。", "postcard"), ("6切。", "6cut")] + { + assert_eq!(paper_name(name), Some(canonical), "{name}"); + } + assert_eq!(paper_name("custom"), None); + assert!(paper_size("poster").is_none()); + // The papers that were already there keep their sizes. + assert_eq!(pt("letter"), (612.0, 792.0)); + assert_eq!(pt("4x6"), (288.0, 432.0)); + assert_eq!(pt("5x7"), (360.0, 504.0)); + assert_eq!(pt("8x10"), (576.0, 720.0)); + assert_eq!(pt("13x19"), (936.0, 1368.0)); + let (w, h) = pt("A4"); + assert!((w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01, "A4 {w} x {h}"); + } + #[test] fn hostile_numbers_are_rejected() { for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index f0d725793..c569a2e1f 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -4,7 +4,7 @@ //! Template tokens (shared with import renaming and export file naming): `{name}` (current name //! without extension), `{num}` (the number at the end of the name: `IMG_0042` → `0042`), `{seq}` / //! `{seq:3}` (sequence number, zero-padded to 3 digits), `{date}` / `{date:%Y%m%d}` (capture date; -//! `%Y %y %m %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`, +//! `%Y %y %m %B %b %d %H %M %S`), `{folder}` (the original's folder name), `{camera}`, `{lens}`, `{iso}`, //! `{rating}`, `{title}`, `{creator}`, `{ext}` (extension, without the dot). Unknown tokens stay as //! typed. Characters that aren't allowed in file names become `-`. The original extension is always //! kept. @@ -25,6 +25,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use lightcraft_catalog::dates::MONTHS; use lightcraft_catalog::{Op, Photo, PhotoId, Source}; use serde::Serialize; @@ -57,9 +58,12 @@ fn sanitize(s: &str) -> String { t.trim().trim_matches('.').trim().to_string() } -/// `%Y%m%d`-style formatting of an ISO local time. +/// `%Y%m%d`-style formatting of an ISO local time. `%B` / `%b` are the English month name +/// (`January` / `Jan`), the same in every interface language so folder names stay put; a month +/// that doesn't parse gives nothing. fn format_date(iso: &str, fmt: &str) -> String { let part = |a: usize, b: usize| iso.get(a..b).unwrap_or("00").to_string(); + let month = iso.get(5..7).and_then(|m| m.parse::().ok()).and_then(|m| m.checked_sub(1)).and_then(|i| MONTHS.get(i)).copied().unwrap_or(""); let mut out = String::new(); let mut it = fmt.chars(); while let Some(c) = it.next() { @@ -71,6 +75,8 @@ fn format_date(iso: &str, fmt: &str) -> String { Some('Y') => out.push_str(&part(0, 4)), Some('y') => out.push_str(&part(2, 4)), Some('m') => out.push_str(&part(5, 7)), + Some('B') => out.push_str(month), + Some('b') => out.push_str(month.get(..3).unwrap_or(month)), Some('d') => out.push_str(&part(8, 10)), Some('H') => out.push_str(&part(11, 13)), Some('M') => out.push_str(&part(14, 16)), @@ -120,6 +126,8 @@ pub const DATE_DIRECTIVES: &[(&str, &str)] = &[ ("%Y", "year, 4 digits"), ("%y", "year, 2 digits"), ("%m", "month 01–12"), + ("%B", "month name (January)"), + ("%b", "short month name (Jan)"), ("%d", "day 01–31"), ("%H", "hour 00–23"), ("%M", "minute"), @@ -1670,6 +1678,8 @@ pub(crate) mod tests { let mut p = sample_photo(); assert_eq!(expand_folder("{date:%Y}/{date:%Y%m%d}", &p, 1), vec!["2026", "20260114"]); assert_eq!(expand_folder("{date:%Y}\\{date:%Y-%m}", &p, 1), vec!["2026", "2026-01"], "backslash separates too"); + assert_eq!(expand_folder("{date:%Y}/{date:%B}", &p, 1), vec!["2026", "January"]); + assert_eq!(expand_folder("{date:%Y}/{date:%m %b}", &p, 1), vec!["2026", "01 Jan"]); // metadata can't add levels or climb out p.meta.camera = "../../etc/x".into(); p.meta.title = "..".into(); @@ -1711,6 +1721,12 @@ pub(crate) mod tests { let v = format_date("2026-01-14T05:58:48", d); assert!(!v.contains('%') || *d == "%%", "{d} is not a known directive"); } + assert_eq!(format_date("2026-09-30T14:05:00", "%B %b"), "September Sep"); + assert_eq!(format_date("2026-12-01T00:00:00", "%d %B %Y"), "01 December 2026"); + // a month that isn't 01–12 gives no name instead of panicking + for bad in ["2026-00-01", "2026-13-01", "2026-x", "", "2026-é1-01"] { + assert_eq!(format_date(bad, "%B|%b"), "|", "{bad:?}"); + } assert_eq!(unknown_tokens("{date}_{camra}-{seq:2}{x}{camra}"), vec!["{camra}".to_string(), "{x}".to_string()]); assert!(unknown_tokens("{name}{ext}{date:%Y}").is_empty()); let json = token_help_json(); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index a835ecba4..d0f174245 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -313,6 +313,30 @@ fn curve_preset_export_never_overwrites_originals_or_sidecars() { preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard"); } +/// #1944: Export Keywords went straight to `std::fs::write`, replacing a library original. +#[test] +fn keyword_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("keyword.export", json!({}), "keyword-export-guard"); +} + +#[test] +fn keyword_export_preserves_the_previous_file_on_write_failure() { + let dir = temp_dir("keyword-export-failure"); + let mut s = Session::with_demo(); + let path = dir.join("keywords.txt"); + let params = json!({"path": path.to_string_lossy()}); + s.execute("keyword.export", ¶ms).unwrap(); + let previous = std::fs::read(&path).unwrap(); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(4); + assert!(s.execute("keyword.export", ¶ms).is_err(), "keyword.export must report the failed write"); + } + assert_eq!(std::fs::read(&path).unwrap(), previous, "the prior export is kept"); + s.execute("keyword.export", ¶ms).unwrap(); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no temporary files remain"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn preset_exports_preserve_previous_files_on_write_failure() { let dir = temp_dir("preset-export-failure"); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index a688426ec..6e5585a82 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1068,6 +1068,54 @@ fn assisted_culling_groups_bursts_across_month_end() { let _ = std::fs::remove_dir_all(&dir); } +/// Assisted culling: `burstGap` and `similarity` move the burst limits (#2275), and values out of +/// range are refused before anything is measured or stored. +#[test] +fn assisted_culling_burst_limits_are_adjustable() { + let dir = temp_dir("cull-limits"); + for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] { + let img = lightcraft_scenes::demo_library()[0].render(w, h); + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png")); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap(); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap(); + // 15 s apart: past the default 10 s, inside a 20 s gap + assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0); + assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1); + // the look-alike pair is a burst at the default similarity, not when it must be identical + let strict = s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20, "similarity": 1.0})).unwrap(); + assert_eq!(strict["groups"], 0, "{strict}"); + // bad values change nothing + let undo_before = s.commits(); + for bad in + [json!({"burstGap": -1}), json!({"burstGap": 86401}), json!({"burstGap": "10"}), json!({"similarity": 1.5}), json!({"similarity": -0.1})] + { + let mut p = bad.clone(); + p["ids"] = json!(ids); + assert!(s.execute("photo.analyze", &p).is_err(), "{bad} must be refused"); + } + assert_eq!(s.commits(), undo_before); + let _ = std::fs::remove_dir_all(&dir); +} + /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 5460dc020..82bdca457 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -514,6 +514,61 @@ fn capture_time_shift_is_bounded() { assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged"); } +/// A capture time that doesn't fully read (a Lightroom catalog's `captureTime` is copied +/// verbatim) is left as it is and counted, not shifted into a clean-looking wrong time, and it +/// isn't mistaken for one that would leave years 0000–9999. +#[test] +fn capture_time_shift_counts_photos_whose_time_does_not_read() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (good, bad) = (ids[0], ids[1]); + for (id, t) in [(good, "2026-01-01T10:00:00"), (bad, "2026-01-01T10:00:00 and more")] { + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap(); + } + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 1})).unwrap(); + assert_eq!(r["unreadable"], 1, "the unreadable photo is counted: {r}"); + assert_eq!(r["changed"], 1, "{r}"); + let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone(); + assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T11:00:00")); + assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T10:00:00 and more"), "left as it was"); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!(captured(&s, good).as_deref(), Some("2026-01-01T10:00:00"), "one undo step"); + // a shift past year 9999 for the readable photo is still the out-of-range refusal, and the + // unreadable one alone isn't + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "hours": 24 * 365 * 9000})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(0), Some(1)), "{r}"); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 24 * 365 * 9000})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("1 photo(s) outside")), "{r:?}"); +} + +/// Setting the active photo's time moves the others by its change, so an active photo whose own +/// time doesn't read can't anchor it: refused, nothing changes. Alone, or setting every photo's +/// time, it gets the time, which repairs it. +#[test] +fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (bad, other) = (ids[0], ids[1]); + let broken = "2026-01-01T10:00:00 and more"; + for (id, t) in [(bad, broken), (other, "2026-01-01T12:00:00")] { + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap(); + } + s.execute("library.select", &serde_json::json!({"ids": [bad.0, other.0], "active": bad.0})).unwrap(); + assert_eq!(s.active(), Some(bad)); + let captured = |s: &crate::Session, id| s.catalog.photo(id).unwrap().captured.clone(); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00"})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("can't be read")), "{r:?}"); + assert_eq!(captured(&s, bad).as_deref(), Some(broken), "refused: unchanged"); + assert_eq!(captured(&s, other).as_deref(), Some("2026-01-01T12:00:00"), "refused: the others don't move"); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "time": "2026-01-01T11:00:00"})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time"); + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()) }).unwrap(); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00", "each": true})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(2), Some(0)), "{r}"); + assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "each repairs it"); +} + /// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is /// refused with the rule's position, by album.createSmart and library.filter alike. #[test] diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 898faa889..51277da04 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -26,7 +26,13 @@ fn get_set_and_reset() { assert_eq!(g["pageWidth"], 8.5); assert_eq!(g["printable"]["x"], 0.25); assert_eq!(g["settings"]["job"]["ppi"], 240.0); - assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4")); + let papers = g["papers"].as_array().unwrap(); + assert!(papers.iter().any(|p| p["name"] == "a4")); + // The Japanese photo and postcard papers are listed with their metric sizes in inches. + let paper = |name: &str| papers.iter().find(|p| p["name"] == name).cloned().unwrap_or_else(|| panic!("{name}")); + let l = paper("l"); + assert!((l["width"].as_f64().unwrap() - 89.0 / 25.4).abs() < 1e-9 && (l["height"].as_f64().unwrap() - 127.0 / 25.4).abs() < 1e-9); + assert!(papers.iter().any(|p| p["name"] == "postcard") && papers.iter().any(|p| p["name"] == "6cut")); assert_eq!(g["gridCells"].as_array().unwrap().len(), 1); let g = run( @@ -213,6 +219,28 @@ fn export_pdf_prints_the_current_settings() { fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); } +/// The Japanese photo and postcard papers reach the PDF page at their metric sizes: はがき is +/// 100 × 148 mm (283.46 × 419.53 pt) and L is 89 × 127 mm (252.28 × 359.99 pt). 254 ppi is exactly +/// ten pixels per millimetre, so the page pixels are whole millimetres and the MediaBox is exact. +#[test] +fn export_pdf_uses_the_japanese_paper_sizes() { + let mut s = Session::with_demo(); + let id = s.catalog.photos().next().map(|p| p.id.0).unwrap(); + for (paper, w_pt, h_pt) in [("postcard", 283.46, 419.53), ("はがき", 283.46, 419.53), ("l", 252.28, 359.99)] { + run(&mut s, "print.set", json!({"layout": {"style": "singleImage", "page": {"paper": paper}}, "job": {"ppi": 254.0}})); + let path = temp_pdf(paper); + run(&mut s, "print.exportPdf", json!({"path": path, "ids": [id]})); + let bytes = std::fs::read(&path).unwrap(); + let (w, h) = media_box(&bytes); + assert!((w - w_pt).abs() < 0.1 && (h - h_pt).abs() < 0.1, "{paper}: {w} x {h} pt, wanted {w_pt} x {h_pt}"); + } + // The customary names are accepted and the page size follows them; an unknown name is refused. + let g = run(&mut s, "print.set", json!({"layout": {"page": {"paper": "はがき"}}})); + assert!((g["pageWidth"].as_f64().unwrap() - 100.0 / 25.4).abs() < 1e-9); + assert!((g["pageHeight"].as_f64().unwrap() - 148.0 / 25.4).abs() < 1e-9); + fails(&mut s, "print.set", json!({"layout": {"page": {"paper": "poster"}}})); +} + #[test] fn export_pdf_with_a_printer_profile() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests_segment.rs index f4b9f4f48..ceb2365fb 100644 --- a/crates/engine/src/tests_segment.rs +++ b/crates/engine/src/tests_segment.rs @@ -77,16 +77,23 @@ fn status_and_model_commands_without_the_model() { #[test] fn a_failing_download_ends_with_an_error_and_never_blocks() { use std::io::{BufRead, BufReader, Write}; - // a local mirror that has nothing + // a local mirror that has nothing; its first answer waits until the test has made its second + // request, so the download is still running then however the threads are scheduled (a 404 at + // once could end it before, and the second request would rightly start a new one) let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}/sam3", l.local_addr().unwrap()); + let (release, released) = std::sync::mpsc::channel::<()>(); std::thread::spawn(move || { + let mut first = Some(released); for mut c in l.incoming().flatten() { let mut r = BufReader::new(c.try_clone().unwrap()); let mut line = String::new(); while r.read_line(&mut line).unwrap_or(0) > 2 { line.clear(); } + if let Some(released) = first.take() { + let _ = released.recv_timeout(Duration::from_secs(10)); + } let _ = c.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 13\r\nConnection: close\r\n\r\n

404

\n"); } }); @@ -103,6 +110,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() { // a second request while it runs starts nothing let again = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap(); assert!(again["started"] == false || again["downloading"] == false); + let _ = release.send(()); let t = Instant::now(); while s.segmenter.download_status().running { assert!(t.elapsed() < Duration::from_secs(30), "the download never ended"); diff --git a/crates/engine/src/text.rs b/crates/engine/src/text.rs index 12974247f..0e2e39c1e 100644 --- a/crates/engine/src/text.rs +++ b/crates/engine/src/text.rs @@ -1,11 +1,12 @@ //! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter -//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines +//! SemiBold (bundled) first, then the craft-fonts CJK and Thai faces as fallbacks ([`faces`]); glyph outlines //! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out //! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor //! it before drawing; drawing hands out per-pixel coverage, and the caller blends. //! -//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers -//! draw as Inter's missing-glyph box: wrong-looking, never a crash. +//! Horizontal text has no contextual shaping or bidi reordering; Thai combining marks use +//! per-character metrics rather than shaped positioning. Arabic faces are not offered. Characters +//! no face covers draw as Inter's missing-glyph box. use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point}; @@ -16,13 +17,16 @@ pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBo pub const MAX_CHARS: usize = 4000; /// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is -/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's -/// missing-glyph box. +/// serif look; then any other CJK face), then Thai faces, preferring Regular. Drawn text has no +/// UI language, so every CJK script is offered. Other script faces stay out until their layout is +/// supported. Without craft-fonts that is Inter alone, with missing-glyph boxes for unsupported text. pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec> { let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); cjk.sort_by_key(|f| !f.is_mincho()); - std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() + let thai = crate::fonts::ui_fallback(craft, "Latn", "Regular") + .into_iter() + .filter(|f| f.covers("Thai") && !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s))); + std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(thai).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() } /// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. @@ -223,3 +227,91 @@ impl TextLayout { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::fonts::{CRAFT_FONTS, CraftFont}; + + fn assert_script_glyphs(script: &str, text: &str) { + if !CRAFT_FONTS.iter().any(|font| font.covers(script)) { + lightcraft_testkit::skip!(Fonts, "built without the {script} face from craft-fonts"); + return; + } + let layout = TextLayout::new(text, 40.0, false, CRAFT_FONTS).unwrap(); + assert_eq!(layout.glyphs.len(), text.chars().count()); + for (ch, (face, glyph, _)) in text.chars().zip(&layout.glyphs) { + assert_ne!(glyph.id.0, 0, "missing {script} glyph for {ch}"); + let font = &layout.fonts[*face]; + assert!(CRAFT_FONTS.iter().any(|entry| entry.covers(script) && entry.bytes == font.font_data())); + } + let mut covered = 0; + layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0)); + assert!(covered > 0, "{script} outlines are rasterized"); + } + + #[test] + fn bundled_arabic_stays_out_of_export_fallback() { + if !CRAFT_FONTS.iter().any(|font| font.covers("Arab")) { + lightcraft_testkit::skip!(Fonts, "built without the Arabic face from craft-fonts"); + return; + } + // UI names still use their existing Arabic face. Export needs joining and bidi first. + assert!(crate::fonts::ui_fallback(CRAFT_FONTS, "Latn", "Regular").iter().any(|font| font.covers("Arab"))); + for vertical in [false, true] { + let layout = TextLayout::new("مرحبا", 40.0, vertical, CRAFT_FONTS).unwrap(); + assert!(layout.glyphs.iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0)); + } + } + + #[test] + fn bundled_thai_uses_its_glyphs() { + assert_script_glyphs("Thai", "สวัสดีครับ"); + } + + #[test] + fn absent_fonts_keep_inter_and_missing_glyph_boxes() { + assert_eq!(faces(&[]).len(), 1); + let layout = TextLayout::new("Aกم", 40.0, false, &[]).unwrap(); + assert_ne!(layout.glyphs[0].1.id.0, 0); + assert!(layout.glyphs[1..].iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0)); + let mut covered = 0; + layout.draw(0.0, 0.0, |_, _, coverage| covered += usize::from(coverage > 0.0)); + assert!(covered > 0); + } + + #[test] + fn latin_and_cjk_rendering_keeps_the_existing_preference() { + let mut cjk: Vec<_> = crate::fonts::cjk(CRAFT_FONTS).collect(); + if cjk.is_empty() { + lightcraft_testkit::skip!(Fonts, "built without CJK faces from craft-fonts"); + return; + } + cjk.sort_by_key(|font| !font.is_mincho()); + let previous: &'static [CraftFont] = Box::leak( + cjk.into_iter() + .map(|font| CraftFont { family: font.family, style: font.style, scripts: font.scripts, bytes: font.bytes }) + .collect::>() + .into_boxed_slice(), + ); + let actual = faces(CRAFT_FONTS); + assert!(actual.len() > previous.len()); + assert!(actual[0].font_data() == TEXT_FONT, "Inter stays first"); + for (font, expected) in actual.iter().skip(1).zip(previous) { + assert!(font.font_data() == expected.bytes, "existing CJK face order: {} {}", expected.family, expected.style); + } + for vertical in [false, true] { + for text in ["LightCraft AV 123", "日本語、。你好世界한국어"] { + let before = TextLayout::new(text, 40.0, vertical, previous).unwrap(); + let after = TextLayout::new(text, 40.0, vertical, CRAFT_FONTS).unwrap(); + assert_eq!((before.width, before.height), (after.width, after.height)); + let pixels = |layout: &TextLayout| { + let mut pixels = Vec::new(); + layout.draw(0.0, 0.0, |x, y, coverage| pixels.push((x, y, coverage))); + pixels + }; + assert!(pixels(&before) == pixels(&after), "{text}, vertical={vertical}"); + } + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7efae6a57..452e2dd67 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -450,11 +450,12 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R } /// Whether the GPU renders requests of this kind at all (a given render can still fall back to -/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on -/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, -/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. -pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { - depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange +/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do +/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and +/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes +/// its slider-drag drafts by it (issue #330), so the two can't drift apart. +pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool { + depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange } /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU @@ -473,7 +474,8 @@ pub fn try_render( // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); let s: &DevelopSettings = &effective; - if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) { + let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some()); + if !enabled() || !handles(req.depth, device_proof, req.overlay) { return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 7f9405492..b46b7cc1a 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -73,6 +73,13 @@ const FIELDS: &[(&str, usize)] = &[ ("OUT_Y", 3), ("OUT_TRC", 1), ("OUT_GAMMA", 1), + // soft proofing (matrix proofs; `ProofParams`): working → proof RGB, its luma, proof → output, warnings + ("PROOF", 1), + ("PROOF_M", 9), + ("PROOF_Y", 3), + ("PROOF_OUT", 9), + ("PROOF_DEST_WARN", 1), + ("PROOF_DISP_WARN", 1), // first row of a band dispatch (the kernel runs over rows Y0.., see `render`) ("Y0", 1), ]; @@ -233,6 +240,14 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr let (trc, gamma) = fp.out_trc.code(); p.u("OUT_TRC", trc); p.f("OUT_GAMMA", gamma); + if let Some(pp) = fp.proof.as_ref().filter(|pp| pp.table.is_none()) { + p.b("PROOF", true); + p.fs("PROOF_M", pp.to_proof.as_flattened()); + p.fs("PROOF_Y", &pp.luma); + p.fs("PROOF_OUT", pp.proof_to_out.as_flattened()); + p.b("PROOF_DEST_WARN", pp.dest_warning); + p.b("PROOF_DISP_WARN", pp.display_warning); + } if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0c5bd19f0..64990db0e 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -669,8 +669,10 @@ pub fn render( // 5. per-pixel stage let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + // soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`) + fp.proof = req.proof.map(|pr| pr.params(req.space)); if let Some(d) = &req.display { - fp.for_display(d, None); + fp.for_display(d, req.proof); } let present = Present { clarity: prep.clarity.is_some(), diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d51308ee0..04115b945 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -252,6 +252,46 @@ fn out_encode(v: f32) -> f32 { return 1.099 * pow(l, 0.45) - 0.099; } +// The 3×3 matrix stored row by row from field `o`. +fn mat_at(o: u32) -> array, 3> { + return array, 3>( + vec3(pf(o), pf(o + 1u), pf(o + 2u)), + vec3(pf(o + 3u), pf(o + 4u), pf(o + 5u)), + vec3(pf(o + 6u), pf(o + 7u), pf(o + 8u)), + ); +} + +// `finish::gamut_map` (SDR): the chroma scale that brings `r` into 0..1 towards its luminance (1: in gamut) ... +fn gamut_scale(r: vec3, luma: vec3) -> f32 { + let yy = clamp(dot(luma, r), 0.0, 1.0); + var tg = 1.0; + for (var k = 0u; k < 3u; k++) { + let cc = r[k]; + if (cc < 0.0) { + tg = min(tg, yy / max(yy - cc, 1e-9)); + } else if (cc > 1.0) { + tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9)); + } + } + return tg; +} + +// ... and `r` scaled by it. +fn gamut_apply(r: vec3, luma: vec3, tg: f32) -> vec3 { + if (tg < 1.0) { + let yy = clamp(dot(luma, r), 0.0, 1.0); + return yy + (r - yy) * tg; + } + return r; +} + +// `finish::out_of_gamut`: `r` needed desaturating by `t` to fit and isn't just a neutral beyond white or below black. +fn out_of_gamut(r: vec3, t: f32) -> bool { + let lo = min(r.x, min(r.y, r.z)); + let hi = max(r.x, max(r.y, r.z)); + return t < 0.995 && (hi - lo) > 0.02 * max(abs(hi), 1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0)); +} + @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) gid: vec3) { let w = pu(F_W); @@ -324,8 +364,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- dehaze (scene linear) let dz = pf(F_DEHAZE) + lt[10]; if (dz != 0.0 && pu(F_HAS_DARK) != 0u) { - let d = clamp(dark[i] / pf(F_AIR_PRE), 0.0, 1.0); + // the dark channel is a blur, not a patch minimum: never attribute more veil than the + // pixel's own minimum channel, or dark pixels next to bright ones crush to black (#2504) let air = pf(F_AIR); + let cmin = min(c.x, min(c.y, c.z)); + let d = clamp(min(select(0.0, dark[i] / pf(F_AIR_PRE), pf(F_AIR_PRE) > 0.0), select(0.0, cmin / air, air > 0.0)), 0.0, 1.0); if (dz > 0.0) { let t = max(1.0 - 0.95 * min(dz, 1.0) * d, 0.12); c = max((c - air * (1.0 - t)) / t, vec3(0.0)); @@ -467,26 +510,27 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } - // --- gamut map to the output space (desaturate towards luminance until in range) - let om = array, 3>( - vec3(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)), - vec3(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)), - vec3(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)), - ); - var r = mul3(om, d); - let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0); - var tg = 1.0; - for (var k = 0u; k < 3u; k++) { - let cc = r[k]; - if (cc < 0.0) { - tg = min(tg, yy / max(yy - cc, 1e-9)); - } else if (cc > 1.0) { - tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9)); + // --- gamut map to the output space (desaturate towards luminance until in range); + // soft proofing maps into the proof space first and shows that in the output space + // (warn: 0 none, 1 destination gamut, 2 display gamut) + var warn = 0u; + var r: vec3; + if (pu(F_PROOF) != 0u) { + let q0 = mul3(mat_at(F_PROOF_M), d); + let tq = gamut_scale(q0, vec3(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u))); + if (pu(F_PROOF_DEST_WARN) != 0u && out_of_gamut(q0, tq)) { + warn = 1u; } + r = mul3(mat_at(F_PROOF_OUT), gamut_apply(q0, vec3(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)), tq)); + } else { + r = mul3(mat_at(F_OUT_M), d); } - if (tg < 1.0) { - r = yy + (r - yy) * tg; + let oy = vec3(pf(F_OUT_Y), pf(F_OUT_Y + 1u), pf(F_OUT_Y + 2u)); + let tg = gamut_scale(r, oy); + if (pu(F_PROOF) != 0u && pu(F_PROOF_DISP_WARN) != 0u && warn == 0u && out_of_gamut(r, tg)) { + warn = 2u; } + r = gamut_apply(r, oy, tg); // --- encode, curves, grain var e = vec3(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z)); @@ -512,6 +556,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e + k; } + if (warn == 1u) { + e = vec3(1.0, 0.0, 0.0); + } else if (warn == 2u) { + e = vec3(0.0, 0.25, 1.0); + } e = vec3(out_encode(e.x), out_encode(e.y), out_encode(e.z)); out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u); } diff --git a/crates/gpu/tests/it/equivalence.rs b/crates/gpu/tests/it/equivalence.rs index c877e3d9d..6a8169b30 100644 --- a/crates/gpu/tests/it/equivalence.rs +++ b/crates/gpu/tests/it/equivalence.rs @@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc, info: &SourceInfo, s: &DevelopSettings, (mean, max) } +/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a +/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding). +/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds. +fn check_warned(name: &str, src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) { + let cpu = render(src, info, s, req).image; + let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render"); + let (mean, _, over) = diff(&cpu, &gpu.image); + let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count(); + let share = flips as f64 / cpu.data.len() as f64; + eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0); + assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB"); +} + type Edit = fn(&mut DevelopSettings); fn typical(s: &mut DevelopSettings) { @@ -877,3 +890,54 @@ fn windows_of_a_big_photo_share_one_uploaded_source() { lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render"); assert_eq!(lightcraft_gpu::source_uploads() - start, 2); } + +#[test] +fn soft_proofs_match() { + if !gpu() { + return; + } + // Soft proofing against an output space (matrix proofs): into the proof gamut, back to the + // output space, with the destination and display gamut warnings painted alike on both renderers. + // Saturated edits put plenty of colours outside the smaller proof spaces. + use lightcraft_pipeline::{OutputSpace, Proof}; + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + for space in [OutputSpace::Srgb, OutputSpace::AdobeRgb, OutputSpace::DisplayP3] { + for (dest_warning, display_warning) in [(false, false), (true, false), (false, true), (true, true)] { + for out in [OutputSpace::Srgb, OutputSpace::Rec2020] { + let mut s = DevelopSettings::default(); + saturated(&mut s); + let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 }); + let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) }; + let name = format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"); + if dest_warning || display_warning { + check_warned(&name, &src, &raw, &s, &req); + } else { + check(&name, &src, &raw, &s, &req); + } + } + } + } + // the case is meaningful: proofing sRGB from Rec. 2020 output warns on some pixels, and the GPU paints the same ones + let mut s = DevelopSettings::default(); + saturated(&mut s); + let req = RenderRequest { + space: OutputSpace::Rec2020, + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 }), + ..RenderRequest::fit(640, 640) + }; + let warned = |i: &Rgba8| i.data.iter().filter(|p| p[1] == 0 && p[2] == 0 && p[0] > 200).count(); + let (cpu, gpu) = (render(&src, &raw, &s, &req).image, lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu render").image); + assert!(warned(&cpu) > 100, "{} warned pixels", warned(&cpu)); + let (a, b) = (warned(&cpu), warned(&gpu)); + assert!(a.abs_diff(b) as f64 <= 1e-4 * cpu.data.len() as f64, "warned pixels: CPU {a}, GPU {b}"); + // an output-device profile (a 3D table) stays on the CPU + let n = 2; + let lut = lightcraft_pipeline::ProofLut::new(n, vec![[0.5, 0.5, 0.5, 0.0]; n * n * n]).unwrap(); + let id = lightcraft_pipeline::register_proof_profile(0x5eed_9f00, lut); + let req = RenderRequest { + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: id }), + ..RenderRequest::fit(64, 64) + }; + assert!(lightcraft_gpu::render(&src, &raw, &DevelopSettings::default(), &req, None).is_none(), "declined: the CPU renders it"); +} diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs index 34a606155..be103c4e5 100644 --- a/crates/merge/src/align.rs +++ b/crates/merge/src/align.rs @@ -5,7 +5,6 @@ use lightcraft_geom::{Homography, Point}; use lightcraft_raster::resample::{Filter, fit}; use lightcraft_raster::{Plane, Rgb32f}; -use rayon::prelude::*; use crate::features::{self, Features}; use crate::ransac::{self, Model}; @@ -129,23 +128,10 @@ pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usiz /// Resample `img` into the reference geometry: `out(p) = img(h(p))` (Lanczos-3). Returns the warped /// image and a validity mask (false where `h(p)` falls outside `img`). pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec) { - let (w, ht) = (img.width, img.height); if *h == Homography::IDENTITY { - return (img.clone(), vec![true; w * ht]); + return (img.clone(), vec![true; img.width * img.height]); } - let mut out = Rgb32f::new(w, ht); - let mut valid = vec![false; w * ht]; - out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| { - for x in 0..w { - let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let (sx, sy) = (p.x as f32, p.y as f32); - if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { - row[x] = img.sample_lanczos3(sx, sy); - vrow[x] = true; - } - } - }); - (out, valid) + crate::flow::warp(img, h, &crate::flow::Flow::default()) } #[cfg(test)] diff --git a/crates/merge/src/flow.rs b/crates/merge/src/flow.rs new file mode 100644 index 000000000..a8a9a6bf9 --- /dev/null +++ b/crates/merge/src/flow.rs @@ -0,0 +1,239 @@ +//! Local alignment: what one motion model for the whole frame leaves. +//! +//! A homography is exact only for a camera that turned about its lens. Handheld, the camera also +//! moves, and things at different distances shift differently (parallax): on a real bracket (Sony, +//! 33 MP, gravel a few metres away and a building a kilometre off) the fit, dominated by the +//! foreground's texture, left the building 2.3 px off, and the merge doubled its inscription. Lens +//! distortion and rolling shutter leave local shifts too. +//! +//! After the global fit ([`crate::align`], [`crate::refine`]) the frame is compared with the +//! reference tile by tile: +//! +//! 1. **Measure.** On a grid of about [`GRID`] tiles along the long edge, the reference's patch at +//! the tile's centre is found in the frame, through the global fit, within [`RADIUS`] pixels +//! ([`crate::refine::match_patch`]: masked correlation, sub-pixel peak, ambiguous and textureless +//! patches dropped). +//! 2. **Clean.** A shift more than [`OUTLIER`] px from the median of its measured neighbours is +//! dropped (a person walking, a mismatch), as is one with no measured neighbour to check it by. Tiles without a measurement take the mean of their +//! measured neighbours, spreading outwards until every tile has one. +//! 3. **Smooth** with two 3 × 3 box passes, so the field bends gently. +//! 4. **Warp.** Each output pixel `p` samples the frame at `h(p + flow(p))` (the flow interpolated +//! bilinearly between tile centres): one Lanczos-3 resampling for both corrections. + +use lightcraft_geom::{Homography, Point}; +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; + +use crate::refine::{self, PATCH}; + +/// Tiles along the long edge. +pub const GRID: usize = 96; +/// Largest local shift measured (pixels); farther ones are filled from neighbours. +pub const RADIUS: usize = 5; +/// A tile this far (pixels) from the median of its neighbours is an outlier. +pub const OUTLIER: f32 = 0.75; + +/// A smooth field of local shifts in reference pixels: one value per tile, tile `(i, j)`'s at +/// `((i + ½)·step, (j + ½)·step)`, so pixel centres of `dx` / `dy` line up with the tiles'. +#[derive(Clone, Debug, PartialEq)] +pub struct Flow { + pub step: usize, + pub dx: Plane, + pub dy: Plane, + /// Tiles that were measured (before filling). + pub measured: usize, +} + +impl Default for Flow { + /// No shift anywhere. + fn default() -> Self { + Flow { step: 0, dx: Plane::new(0, 0), dy: Plane::new(0, 0), measured: 0 } + } +} + +impl Flow { + /// The shift at reference coordinates (pixel centres at +0.5), bilinear between tile centres; + /// zero for an empty flow. + pub fn at(&self, x: f64, y: f64) -> (f64, f64) { + let (fx, fy) = ((x / self.step as f64) as f32, (y / self.step as f64) as f32); + if self.dx.data.is_empty() || !fx.is_finite() || !fy.is_finite() { + return (0.0, 0.0); + } + (crate::features::bilinear(&self.dx, fx, fy) as f64, crate::features::bilinear(&self.dy, fx, fy) as f64) + } + + /// The largest shift in the field (pixels). + pub fn max(&self) -> f32 { + self.dx.data.iter().zip(&self.dy.data).map(|(x, y)| x.hypot(*y)).fold(0.0, f32::max) + } +} + +/// The local shifts between `reference` and `frame` (both from [`refine::luma`]) once `h` (reference +/// → frame pixels) is applied, within `radius` pixels. +pub fn estimate(reference: &Plane, frame: &Plane, h: &Homography, radius: usize) -> Flow { + let (w, ht) = (reference.width, reference.height); + // an even step, so a patch centred on a tile has a whole-pixel corner + let step = ((w.max(ht) / GRID).max(PATCH) + 1) & !1; + let (gw, gh) = (w / step, ht / step); + if gw == 0 || gh == 0 || frame.data.is_empty() { + return Flow::default(); + } + let r = radius.max(1); + let raw: Vec> = (0..gw * gh) + .into_par_iter() + .map(|t| { + let (i, j) = (t % gw, t / gw); + let (sx, sy) = (i * step + (step - PATCH) / 2, j * step + (step - PATCH) / 2); + if sx < r + 1 || sy < r + 1 || sx + PATCH + r + 1 > w || sy + PATCH + r + 1 > ht { + return None; + } + refine::match_patch(reference, (sx, sy), frame, h, r).map(|m| [m.offset.x as f32, m.offset.y as f32]) + }) + .collect(); + let measured = raw.iter().filter(|v| v.is_some()).count(); + let at = |d: &[Option<[f32; 2]>], k: usize| d.get(k).copied().flatten(); + // 2. outliers against the median of their measured neighbours + let mut d: Vec> = (0..gw * gh) + .map(|t| { + let v = at(&raw, t)?; + let n = neighbours(|k| at(&raw, k), gw, gh, t); + let med = [median(n.iter().map(|v| v[0]).collect()), median(n.iter().map(|v| v[1]).collect())]; + // a tile with no measured neighbour can't be checked, and the fill would spread it: dropped + (!n.is_empty() && (v[0] - med[0]).hypot(v[1] - med[1]) <= OUTLIER).then_some(v) + }) + .collect(); + // fill outwards from measured tiles + for _ in 0..gw + gh { + if d.iter().all(Option::is_some) || d.iter().all(Option::is_none) { + break; + } + let prev = d.clone(); + for (t, slot) in d.iter_mut().enumerate().filter(|(_, s)| s.is_none()) { + *slot = mean(&neighbours(|k| at(&prev, k), gw, gh, t)); + } + } + let mut f: Vec<[f32; 2]> = d.into_iter().map(|v| v.unwrap_or([0.0; 2])).collect(); + // 3. smooth (each tile with its neighbours) + for _ in 0..2 { + let prev = f.clone(); + for (t, v) in f.iter_mut().enumerate() { + let mut n = neighbours(|k| prev.get(k).copied(), gw, gh, t); + n.push(*v); + *v = mean(&n).unwrap_or(*v); + } + } + let plane = |c: usize| Plane::from_fn(gw, gh, |i, j| f.get(j * gw + i).map_or(0.0, |v| v[c])); + Flow { step, dx: plane(0), dy: plane(1), measured } +} + +/// The values `get` has among the 8 neighbours of tile `t`. +fn neighbours(get: impl Fn(usize) -> Option<[f32; 2]>, gw: usize, gh: usize, t: usize) -> Vec<[f32; 2]> { + let (i, j) = (t % gw, t / gw); + let mut out = Vec::with_capacity(8); + for b in j.saturating_sub(1)..=(j + 1).min(gh - 1) { + for a in i.saturating_sub(1)..=(i + 1).min(gw - 1) { + if (a, b) != (i, j) + && let Some(v) = get(b * gw + a) + { + out.push(v); + } + } + } + out +} + +fn mean(n: &[[f32; 2]]) -> Option<[f32; 2]> { + let k = n.len() as f32; + (!n.is_empty()).then(|| [n.iter().map(|v| v[0]).sum::() / k, n.iter().map(|v| v[1]).sum::() / k]) +} + +fn median(mut v: Vec) -> f32 { + v.sort_by(|a, b| a.total_cmp(b)); + v.get(v.len() / 2).copied().unwrap_or(0.0) +} + +/// `img` resampled into the reference's geometry through `h` after the local shifts: +/// `out(p) = img(h(p + flow(p)))` (Lanczos-3), and where that falls inside `img`. +pub fn warp(img: &Rgb32f, h: &Homography, flow: &Flow) -> (Rgb32f, Vec) { + let (w, ht) = (img.width, img.height); + let mut out = Rgb32f::new(w, ht); + let mut valid = vec![false; w * ht]; + if w == 0 || ht == 0 { + return (out, valid); + } + out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| { + for x in 0..w { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let (dx, dy) = flow.at(px, py); + let q = h.apply(Point::new(px + dx, py + dy)); + let (sx, sy) = (q.x as f32, q.y as f32); + if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { + row[x] = img.sample_lanczos3(sx, sy); + vrow[x] = true; + } + } + }); + (out, valid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::refine::tests::tex; + + /// Parallax: the top of the frame (far) shifted 2.2 px against the bottom (near), with a + /// smooth band between. The flow finds both, and the warp lines the frame up everywhere. + #[test] + fn finds_and_undoes_a_local_shift() { + let (w, h) = (1200usize, 800usize); + let shift = |y: f64| { + let t = ((y - 300.0) / 200.0).clamp(0.0, 1.0); + let s = t * t * (3.0 - 2.0 * t); + ((1.0 - s) * 2.2, (1.0 - s) * -1.1) + }; + let reference = Plane::from_fn(w, h, |x, y| tex(x as f64 + 0.5, y as f64 + 0.5)); + // the frame's pixel q shows the scene at q + shift(q.y) + let frame = Rgb32f::from_fn(w, h, |x, y| { + let (sx, sy) = shift(y as f64 + 0.5); + [tex(x as f64 + 0.5 + sx, y as f64 + 0.5 + sy); 3] + }); + let fl = refine::luma(&frame, 1.0, 10.0); + let flow = estimate(&reference, &fl, &Homography::IDENTITY, RADIUS); + let tiles = flow.dx.width * flow.dx.height; + assert!(flow.measured > tiles / 2, "{} of {tiles}", flow.measured); + // reference p matches frame p + d with d = −shift + let (tx, ty) = flow.at(600.0, 100.0); + assert!((tx + 2.2).abs() < 0.15 && (ty - 1.1).abs() < 0.15, "top {tx} {ty}"); + let (bx, by) = flow.at(600.0, 700.0); + assert!(bx.abs() < 0.15 && by.abs() < 0.15, "bottom {bx} {by}"); + let (out, valid) = warp(&frame, &Homography::IDENTITY, &flow); + let error = |img: &Rgb32f| { + let (mut e, mut n) = (0f64, 0usize); + for y in (40..h - 40).step_by(3) { + for x in (40..w - 40).step_by(3) { + if valid[y * w + x] { + e += (img.get(x, y)[0] - reference.get(x, y)).abs() as f64; + n += 1; + } + } + } + e / n as f64 + }; + let (before, after) = (error(&frame), error(&out)); + assert!(after < before / 6.0, "mean error {before} unwarped, {after} warped"); + } + + #[test] + fn empty_and_flat_inputs_give_no_shift() { + let flat = Plane::from_fn(400, 300, |_, _| 0.5); + let f = estimate(&flat, &flat, &Homography::IDENTITY, RADIUS); + assert_eq!(f.measured, 0); + assert_eq!(f.max(), 0.0); + assert_eq!(estimate(&Plane::new(0, 0), &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default()); + assert_eq!(estimate(&flat, &Plane::new(0, 0), &Homography::IDENTITY, 3), Flow::default()); + assert_eq!(Flow::default().at(5.0, f64::NAN), (0.0, 0.0)); + assert_eq!(f.at(f64::INFINITY, 3.0), (0.0, 0.0)); + let (img, v) = warp(&Rgb32f::new(0, 0), &Homography::IDENTITY, &Flow::default()); + assert!(img.data.is_empty() && v.is_empty()); + } +} diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index 4b4e206e6..b5ccd4bb8 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -15,12 +15,14 @@ pub mod align; pub mod features; +pub mod flow; pub mod frame; pub mod hdr; pub mod linalg; pub mod output; pub mod pano; pub mod ransac; +pub mod refine; pub mod synth; pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with}; diff --git a/crates/merge/src/refine.rs b/crates/merge/src/refine.rs new file mode 100644 index 000000000..e9044bc8b --- /dev/null +++ b/crates/merge/src/refine.rs @@ -0,0 +1,359 @@ +//! Full-resolution refinement of an alignment found on small versions of the frames. +//! +//! [`crate::align`] registers frames on versions of at most [`crate::align::WORK_EDGE`] pixels. +//! For a 24–60 MP photo that is 5–8× smaller, and a tenth of a work pixel off is most of a full +//! pixel off: merged frames that far apart blur the result. Here the motion is fitted again at +//! full size: +//! +//! 1. **Sites.** The reference is cut into a grid of cells (≈ [`CELLS`] along the long edge); in +//! each, the [`PATCH`]-pixel square with the most two-dimensional texture (the smaller +//! eigenvalue of its gradient structure tensor, so a straight edge, which only fixes one +//! direction, doesn't count) is a site. +//! 2. **Matching.** Each site's patch is compared with the frame resampled through the current +//! alignment, by normalised cross-correlation (immune to the frames' different brightness and +//! contrast) over integer offsets within a search radius, counting only pixels usable in both +//! (clipped ones aren't); the peak is refined to a fraction of a pixel by a parabola through its +//! neighbours on each axis. Patches less than half usable, with too little texture, a weak peak, a peak on the search border or a second peak almost +//! as strong (repeating texture) are dropped. +//! 3. **Fit.** The matches are fitted with RANSAC at [`THRESH`] pixels, choosing the simplest of +//! translation / similarity / homography that explains (nearly) as many as the most general +//! one, as [`crate::align::align_pair`] does. A second, narrower pass starts from that fit. +//! +//! When too few patches match, or the fit isn't a plausible handheld motion, the alignment found +//! on the small versions stays: refining never makes an alignment worse than not refining. + +use lightcraft_geom::{Homography, Point}; +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; + +use crate::align::{Alignment, Models, choose_model, luma as lum}; +use crate::ransac::Model; + +/// Side of a matched patch (pixels). +pub const PATCH: usize = 48; +/// Cells along the long edge (sites are picked one per cell). +pub const CELLS: usize = 24; +/// RANSAC inlier threshold at full resolution (pixels). +pub const THRESH: f64 = 0.75; +/// Weakest correlation peak a match may have. +const MIN_SCORE: f32 = 0.8; +/// The best peak away from the best one must be at least this much weaker. +const UNIQUE: f32 = 0.03; +/// Least standard deviation (of the gamma-encoded luminance) a patch needs to be matched. +const MIN_STD: f32 = 0.01; +/// Fewest matches a fit is made from. +const MIN_MATCHES: usize = 12; +/// Share of the matches the fit must explain. +const MIN_SHARE: f64 = 0.4; + +/// A frame's luminance for matching: `(luminance · gain)^(1/2.2)` at full size, NaN where the +/// frame is clipped (any channel at or above 95 % of `clip`) or `gain` takes it past 1, so such +/// pixels never take part. `gain` brings frames of a bracket to the same brightness. +pub fn luma(img: &Rgb32f, gain: f32, clip: f32) -> Plane { + let g = if gain.is_finite() && gain > 0.0 { gain } else { 1.0 }; + img.map(|c| { + if c[0].max(c[1]).max(c[2]) >= 0.95 * clip { + return f32::NAN; + } + let y = lum(c) * g; + if y.is_finite() && y < 1.0 { y.max(0.0).powf(1.0 / 2.2) } else { f32::NAN } + }) +} + +/// The search radius (full-resolution pixels) for an alignment found at `scale` (work pixels per +/// full pixel): about two work pixels. +pub fn radius_for(scale: f64) -> usize { + if scale.is_finite() && scale > 0.0 { ((2.0 / scale).ceil() as usize + 1).clamp(3, 16) } else { 3 } +} + +/// A patch of the reference found in a frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Match { + /// Centre of the patch in the reference (pixel centres at +0.5). + pub reference: Point, + /// Where it is in the frame. + pub frame: Point, + /// The offset from where the alignment put it, in reference pixels. + pub offset: Point, + /// Correlation at the peak (−1..1). + pub score: f32, +} + +/// Top-left corners of the patches to match: one per cell, where the reference has the most +/// two-dimensional texture, `margin` pixels clear of the edges. +pub fn sites(reference: &Plane, margin: usize) -> Vec<(usize, usize)> { + let (w, h) = (reference.width, reference.height); + let need = PATCH + 2 * margin; + if w < need || h < need { + return Vec::new(); + } + let long = w.max(h); + let cell = long.div_ceil(CELLS).max(PATCH); + let (nx, ny) = ((w - 2 * margin) / cell, (h - 2 * margin) / cell); + let (nx, ny) = (nx.max(1), ny.max(1)); + let (cw, chh) = ((w - 2 * margin) / nx, (h - 2 * margin) / ny); + (0..nx * ny) + .into_par_iter() + .filter_map(|c| { + let (cx, cy) = (margin + (c % nx) * cw, margin + (c / nx) * chh); + let span_x = cw.saturating_sub(PATCH); + let span_y = chh.saturating_sub(PATCH); + let mut best: Option<((usize, usize), f32)> = None; + for j in 0..3 { + for i in 0..3 { + let (x, y) = (cx + span_x * i / 2, cy + span_y * j / 2); + if x + PATCH > w - margin || y + PATCH > h - margin { + continue; + } + if let Some(t) = texture(reference, x, y) + && best.is_none_or(|(_, b)| t > b) + { + best = Some(((x, y), t)); + } + } + } + best.filter(|(_, t)| *t > 1e-6).map(|(p, _)| p) + }) + .collect() +} + +/// The smaller eigenvalue of the patch's mean gradient structure tensor, over its usable pixels; +/// `None` when fewer than half of them are. +fn texture(p: &Plane, x0: usize, y0: usize) -> Option { + let w = p.width; + let at = |x: usize, y: usize| p.data.get(y * w + x).copied().unwrap_or(f32::NAN); + let (mut sxx, mut syy, mut sxy, mut n) = (0f64, 0f64, 0f64, 0usize); + for y in y0 + 1..y0 + PATCH - 1 { + for x in x0 + 1..x0 + PATCH - 1 { + let gx = (at(x + 1, y) - at(x - 1, y)) as f64; + let gy = (at(x, y + 1) - at(x, y - 1)) as f64; + if gx.is_finite() && gy.is_finite() { + sxx += gx * gx; + syy += gy * gy; + sxy += gx * gy; + n += 1; + } + } + } + if n * 2 < (PATCH - 2) * (PATCH - 2) { + return None; + } + let n = n as f64; + let (a, b, c) = (sxx / n, syy / n, sxy / n); + let tr = a + b; + let det = a * b - c * c; + Some((tr / 2.0 - ((tr * tr / 4.0 - det).max(0.0)).sqrt()) as f32) +} + +/// Find the reference patch at `site` (top-left) in `frame`, which `h` maps reference pixels into, +/// searching offsets up to `radius` pixels. +pub fn match_patch(reference: &Plane, site: (usize, usize), frame: &Plane, h: &Homography, radius: usize) -> Option { + let (sx, sy) = site; + let (rw, rh) = (reference.width, reference.height); + if sx + PATCH > rw || sy + PATCH > rh || frame.width == 0 || frame.height == 0 { + return None; + } + let r = radius.max(1); + let s = PATCH + 2 * r; + let mut pr: Vec = Vec::with_capacity(PATCH * PATCH); + for y in sy..sy + PATCH { + for x in sx..sx + PATCH { + pr.push(reference.data.get(y * rw + x).copied().unwrap_or(f32::NAN)); + } + } + // the frame around the patch, through `h`; NaN where it can't be used + let (fw, fh) = (frame.width as f64, frame.height as f64); + let mut win = vec![f32::NAN; s * s]; + for j in 0..s { + for i in 0..s { + let q = h.apply(Point::new((sx + i) as f64 - r as f64 + 0.5, (sy + j) as f64 - r as f64 + 0.5)); + if !(q.x >= 0.5 && q.y >= 0.5 && q.x <= fw - 0.5 && q.y <= fh - 0.5) { + return None; + } + win[j * s + i] = crate::features::bilinear(frame, q.x as f32, q.y as f32); + } + } + let d = 2 * r + 1; + // offsets that can't be scored (too few usable pixels) stay at −∞ + let mut score = vec![f32::NEG_INFINITY; d * d]; + for dy in 0..d { + for dx in 0..d { + if let Some(c) = correlation(&pr, |u, v| win.get((v + dy) * s + dx + u).copied().unwrap_or(f32::NAN)) { + score[dy * d + dx] = c; + } + } + } + let (bi, &best) = score.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))?; + let (bx, by) = (bi % d, bi / d); + if best < MIN_SCORE || bx == 0 || by == 0 || bx == d - 1 || by == d - 1 { + return None; + } + // a second peak nearly as strong: repeating texture, the match is ambiguous + let second = + score.iter().enumerate().filter(|(k, _)| (k % d).abs_diff(bx) > 1 || (k / d).abs_diff(by) > 1).map(|(_, v)| *v).fold(-1f32, f32::max); + if second > best - UNIQUE { + return None; + } + let at = |x: usize, y: usize| score[y * d + x]; + // the sub-pixel parabola needs the peak's four neighbours: one that couldn't be scored would pull it away + if [at(bx - 1, by), at(bx + 1, by), at(bx, by - 1), at(bx, by + 1)].iter().any(|v| !v.is_finite()) { + return None; + } + let sub = |m: f32, c: f32, p: f32| { + let den = m - 2.0 * c + p; + if den < -1e-9 { (0.5 * (m - p) / den).clamp(-0.5, 0.5) } else { 0.0 } + }; + let ox = bx as f64 - r as f64 + sub(at(bx - 1, by), best, at(bx + 1, by)) as f64; + let oy = by as f64 - r as f64 + sub(at(bx, by - 1), best, at(bx, by + 1)) as f64; + let c = Point::new(sx as f64 + PATCH as f64 / 2.0, sy as f64 + PATCH as f64 / 2.0); + Some(Match { reference: c, frame: h.apply(Point::new(c.x + ox, c.y + oy)), offset: Point::new(ox, oy), score: best }) +} + +/// Correlation of the reference patch `a` (row-major, [`PATCH`] wide) with `b(u, v)` over the +/// pixels usable in both; `None` with fewer than half of the patch usable, or too little texture in +/// either. +fn correlation(a: &[f32], b: impl Fn(usize, usize) -> f32) -> Option { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0usize, 0f64, 0f64, 0f64, 0f64, 0f64); + for v in 0..PATCH { + for u in 0..PATCH { + let (x, y) = (a.get(v * PATCH + u).copied().unwrap_or(f32::NAN), b(u, v)); + if x.is_finite() && y.is_finite() { + let (x, y) = (x as f64, y as f64); + n += 1; + sa += x; + sb += y; + saa += x * x; + sbb += y * y; + sab += x * y; + } + } + } + if n * 2 < PATCH * PATCH { + return None; + } + let nf = n as f64; + let (ma, mb) = (sa / nf, sb / nf); + let (va, vb) = (saa / nf - ma * ma, sbb / nf - mb * mb); + let min = (MIN_STD as f64).powi(2); + if !(va >= min && vb >= min * 0.25) { + return None; + } + let c = (sab / nf - ma * mb) / (va * vb).sqrt(); + c.is_finite().then_some(c as f32) +} + +/// Translation, similarity and homography: what a handheld frame may need. +pub const ALL_MODELS: [(Model, &str, usize); 3] = + [(Model::Translation, "translation", 6), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 12)]; + +/// Search radius (pixels) for a frame with no alignment to start from (the small versions found +/// none: a bright frame mostly clipped, too little texture left for features). +pub const RECOVER_RADIUS: usize = 16; + +/// Fit `models` (reference → frame, full resolution) to patches of `reference` matched in `frame` +/// around `start` within `radius` pixels, then again within 2 px of that fit; both planes from +/// [`luma`]. `start` is an alignment found on small versions (`radius` from [`radius_for`]), or the +/// identity with [`RECOVER_RADIUS`]. `None` when too few patches match or no fit is a plausible +/// handheld motion: the caller keeps what it had. +pub fn fit(reference: &Plane, frame: &Plane, start: &Homography, radius: usize, seed: u64, models: &Models) -> Option { + let (w, ht) = (reference.width as f64, reference.height as f64); + let r0 = radius.max(2); + // the sites for the widest search fit the narrower one too + let s = sites(reference, r0 + 2); + let mut cur: Option = None; + for r in [r0, 2] { + let h = cur.as_ref().map_or(*start, |a| a.h); + let m: Vec = s.par_iter().filter_map(|&p| match_patch(reference, p, frame, &h, r)).collect(); + if m.len() < MIN_MATCHES { + break; + } + let pa: Vec = m.iter().map(|m| m.reference).collect(); + let pb: Vec = m.iter().map(|m| m.frame).collect(); + let Some((name, f)) = choose_model(models, &pa, &pb, THRESH, MIN_SHARE, (w, ht), seed) else { break }; + cur = Some(Alignment { h: f.h, model: name, inliers: f.inliers.len(), rms: f.rms }); + } + cur +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A smooth random texture, sampled anywhere. + pub(crate) fn tex(x: f64, y: f64) -> f32 { + let mut v = 0.45; + for k in 0..9 { + let a = k as f64 * 2.399; + let f = 0.02 + 0.025 * k as f64; + v += 0.05 * ((x * a.cos() + y * a.sin()) * f * std::f64::consts::TAU + k as f64).sin(); + } + v as f32 + } + + fn plane(w: usize, h: usize, map: &Homography) -> Plane { + Plane::from_fn(w, h, |x, y| { + let p = map.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + tex(p.x, p.y) + }) + } + + fn corner_error(a: &Homography, b: &Homography, w: f64, h: f64) -> f64 { + [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| a.apply(Point::new(x, y)).dist(b.apply(Point::new(x, y)))).fold(0.0, f64::max) + } + + /// A frame rotated 0.2° and moved 1.37 px, given an alignment 2 px off: refined to a tenth of + /// a pixel at the corners. + #[test] + fn refines_a_rough_alignment_to_a_fraction_of_a_pixel() { + let (w, h) = (900usize, 600usize); + let reference = plane(w, h, &Homography::IDENTITY); + // the frame's pixel q shows the scene at s(q); reference → frame is s⁻¹ + let (c, sn) = (0.2f64.to_radians().cos(), 0.2f64.to_radians().sin()); + let s = Homography([c, -sn, 1.37, sn, c, -0.83, 0.0, 0.0, 1.0]); + let truth = s.inverse().unwrap(); + let frame = plane(w, h, &s); + let rough = Homography([truth.0[0], truth.0[1], truth.0[2] + 1.6, truth.0[3], truth.0[4], truth.0[5] - 1.2, 0.0, 0.0, 1.0]); + assert!(corner_error(&rough, &truth, w as f64, h as f64) > 1.5); + let r = fit(&reference, &frame, &rough, 4, 1, &ALL_MODELS).unwrap(); + let e = corner_error(&r.h, &truth, w as f64, h as f64); + assert!(e < 0.1, "corner error {e} px, model {}", r.model); + assert!(r.inliers >= MIN_MATCHES); + } + + /// A frame the small versions couldn't align, 5 px and 0.1° off: found from nothing. + #[test] + fn recovers_a_frame_with_no_alignment() { + let (w, h) = (900usize, 600usize); + let reference = plane(w, h, &Homography::IDENTITY); + let (c, sn) = (0.1f64.to_radians().cos(), 0.1f64.to_radians().sin()); + let s = Homography([c, -sn, -4.6, sn, c, 2.1, 0.0, 0.0, 1.0]); + let r = fit(&reference, &plane(w, h, &s), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).unwrap(); + let e = corner_error(&r.h, &s.inverse().unwrap(), w as f64, h as f64); + assert!(e < 0.1, "corner error {e} px, model {}", r.model); + assert!(fit(&reference, &Plane::from_fn(w, h, |_, _| 0.5), &Homography::IDENTITY, RECOVER_RADIUS, 1, &ALL_MODELS).is_none()); + } + + /// Nothing to match (a flat frame, planes too small for a patch, empty ones): no fit. + #[test] + fn no_fit_when_nothing_matches() { + let h = Homography([1.0, 0.0, 0.7, 0.0, 1.0, 0.2, 0.0, 0.0, 1.0]); + let flat = Plane::from_fn(400, 300, |_, _| 0.5); + assert!(fit(&flat, &flat, &h, 4, 1, &ALL_MODELS).is_none()); + let tiny = Plane::from_fn(20, 20, |x, _| x as f32); + assert!(fit(&tiny, &tiny, &h, 4, 1, &ALL_MODELS).is_none()); + assert!(fit(&Plane::new(0, 0), &Plane::new(0, 0), &h, 4, 1, &ALL_MODELS).is_none()); + } + + #[test] + fn clipped_pixels_and_odd_gains_are_left_out() { + let img = Rgb32f::from_fn(4, 1, |x, _| [x as f32 * 0.33; 3]); + let l = luma(&img, 1.0, 1.0); + assert!(l.data[0] == 0.0 && l.data[3].is_nan(), "{:?}", l.data); + let l = luma(&img, 4.0, 1.0); + assert!(l.data[1].is_nan(), "0.33 × 4 is past 1: {:?}", l.data); + assert!(luma(&img, f32::NAN, 1.0).data[1].is_finite()); + assert_eq!(radius_for(0.125), 16); + assert_eq!(radius_for(1.0), 3); + assert_eq!(radius_for(f64::NAN), 3); + } +} diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index cb45274d1..f24bb5f2f 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -456,6 +456,14 @@ pub(crate) mod tests { } } +/// An exact `1/N` rational for a sub-second value whose reciprocal is a whole number (shutter speeds such as +/// 1/12800 s, which [`lightcraft_tiff::writer::rational`]'s decimal denominators cannot represent). +fn reciprocal(v: Option) -> Option { + let v = v.filter(|v| v.is_finite() && *v > 0.0 && *v < 1.0)?; + let inv = 1.0 / v; + ((inv - inv.round()).abs() < 1e-6 && inv.round() <= u32::MAX as f64).then(|| lightcraft_tiff::Value::Rational(vec![(1, inv.round() as u32)])) +} + /// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no /// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation, /// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD. @@ -483,7 +491,7 @@ pub fn write_exif(meta: &Metadata) -> Vec { let r = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)])); for (tag, v) in [ (t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), - (t::EXPOSURE_TIME, r(meta.exposure_time)), + (t::EXPOSURE_TIME, reciprocal(meta.exposure_time).or_else(|| r(meta.exposure_time))), (t::F_NUMBER, r(meta.f_number)), (t::FOCAL_LENGTH, r(meta.focal_length)), (t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))), @@ -561,4 +569,13 @@ mod write_tests { assert_eq!(back.iso, Some(iso)); } } + + #[test] + fn fast_shutter_speeds_round_trip_exactly() { + for den in [8000.0, 12_800.0, 16_000.0, 32_000.0] { + let back = read_exif(&write_exif(&Metadata { exposure_time: Some(1.0 / den), ..Default::default() })); + let got = back.exposure_time.unwrap(); + assert!((1.0 / got - den).abs() < 0.5, "1/{den} s came back as 1/{}", 1.0 / got); + } + } } diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index 197020e45..b62588b6d 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -19,6 +19,7 @@ rayon = { workspace = true } serde = { workspace = true } [dev-dependencies] +dhat = { workspace = true } lightcraft-scenes = { workspace = true } proptest = { workspace = true } image = { workspace = true } diff --git a/crates/pipeline/examples/guided_memory.rs b/crates/pipeline/examples/guided_memory.rs new file mode 100644 index 000000000..9f6beaa2a --- /dev/null +++ b/crates/pipeline/examples/guided_memory.rs @@ -0,0 +1,84 @@ +//! Isolated fast-guided-filter benchmark (procedural log luminance; no decoded RGB or tone median). +//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-pipeline --example guided_memory -- new` +//! Replace `new` with `old` for the materialized-coefficient reference. Optional positional arguments: +//! width height sigma iterations (defaults: 6000 4000 120 5). Append `--heap` for one dhat-counted run. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use lightcraft_pipeline::local::{BASE_EPS, guided, guided_fast, guided_fast_step}; +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Plane, par_join, par_rows}; +use std::hint::black_box; +use std::time::Instant; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// Previous algorithm; kept here only to make before/after measurements reproducible. +fn materialized(p: &Plane, sigma: f32) -> Plane { + let s = guided_fast_step(sigma); + if s <= 1 { + return guided(p, sigma, BASE_EPS); + } + let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box); + let (a, b) = coefficients(&lo, sigma / s as f32); + let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear)); + let mut q = Plane::new(p.width, p.height); + par_rows(&mut q.data, p.width, |y, row| { + for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { + *v = av * pv + bv; + } + }); + q +} + +fn coefficients(lo: &Plane, sg: f32) -> (Plane, Plane) { + let (mean, corr) = par_join(|| gaussian(lo, sg), || gaussian(&lo.map(|v| v * v), sg)); + let a = corr.zip_map(&mean, |c, m| { + let v = (c - m * m).max(0.0); + v / (v + BASE_EPS) + }); + let b = mean.zip_map(&a, |m, a| m - a * m); + par_join(|| gaussian(&a, sg), || gaussian(&b, sg)) +} + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let mode = args.first().map(String::as_str).unwrap_or("new"); + if !matches!(mode, "old" | "new") { + return Err("mode must be old or new".into()); + } + let parse = |i: usize, default: usize| -> Result> { + Ok(args.get(i).filter(|v| !v.starts_with("--")).map(|v| v.parse()).transpose()?.unwrap_or(default)) + }; + let (w, h, sigma, n) = (parse(1, 6000)?, parse(2, 4000)?, parse(3, 120)? as f32, parse(4, 5)?.max(1)); + if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > 100_000_000) { + return Err("dimensions must be positive and at most 100 MP".into()); + } + let heap = args.iter().any(|a| a == "--heap"); + let p = Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 97.0 - 4.0 + if x > w / 2 { 2.0 } else { 0.0 }); + let filter = || if mode == "old" { materialized(&p, sigma) } else { guided_fast(&p, sigma, BASE_EPS) }; + // Initialize Rayon and warm code paths before counting. The source is excluded from heap stats. + black_box(filter()); + let profiler = heap.then(|| dhat::Profiler::builder().testing().build()); + let mut best = f64::MAX; + for _ in 0..if heap { 1 } else { n } { + let t = Instant::now(); + let q = black_box(filter()); + best = best.min(t.elapsed().as_secs_f64() * 1000.0); + if heap { + let stats = dhat::HeapStats::get(); + println!( + "peak_bytes={} total_bytes={} total_blocks={} retained_output_bytes={}", + stats.max_bytes, + stats.total_bytes, + stats.total_blocks, + q.data.len() * size_of::() + ); + } + } + drop(profiler); + println!("{mode} {w}x{h} sigma={sigma} step={} heap={heap} min_wall_ms={best:.3}", guided_fast_step(sigma)); + Ok(()) +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index eb65d51a2..b61b6a62c 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -450,7 +450,12 @@ pub(crate) fn finish_with( if dz != 0.0 && let Some(dark) = &p.dark { - let d = (dark.data[i] / air_pre).clamp(0.0, 1.0); + // The dark channel is a blur, not a patch minimum: near bright neighbours it credits + // dark pixels with veil they don't hold (and a windowed render borrows the whole + // frame's airlight), so the subtraction went past zero and crushed them to black + // (#2504). Never attribute more veil than the pixel's own minimum channel. + let c_min = c[0].min(c[1]).min(c[2]); + let d = (if air_pre > 0.0 { dark.data[i] / air_pre } else { 0.0 }).min(if air > 0.0 { c_min / air } else { 0.0 }).clamp(0.0, 1.0); if dz > 0.0 { let t = (1.0 - 0.95 * dz.min(1.0) * d).max(0.12); c = c.map(|v| ((v - air * (1.0 - t)) / t).max(0.0)); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 34df1f8a9..57cec33ec 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -139,7 +139,7 @@ pub struct RenderRequest { pub space: OutputSpace, /// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports). pub depth: OutputDepth, - /// Soft proofing (CPU only; see [`Proof`]). + /// Soft proofing (a proof against an output-device profile renders on the CPU; see [`Proof`]). pub proof: Option, /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 705b4026e..c91872d38 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -178,10 +178,55 @@ fn guided_coeffs(p: &Plane, sigma: f32, eps: f32) -> (Plane, Plane) { par_join(|| gaussian(&a, sigma), || gaussian(&b, sigma)) } -/// `q = a·p + b` per pixel. +/// `q = a·p + b` per pixel. Reduced coefficients are sampled with the same separable +/// weights and accumulation order as a bilinear resize, without full-size coefficient planes. fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { let mut q = Plane::new(p.width, p.height); let w = p.width; + if (a.width, a.height) != (p.width, p.height) { + use lightcraft_raster::resample::{Filter, weights}; + let wx = weights(a.width, w, Filter::Bilinear); + let wy = weights(a.height, p.height, Filter::Bilinear); + // Bilinear upsampling has at most four taps (including zero-weight boundary taps). + // Keep only those horizontally sampled rows per band, reusing them for neighbouring + // output rows. Scratch scales with width and active bands, not image area. + const BAND: usize = 32; + for_rows(&mut q.data, w.saturating_mul(BAND), |band, rows| { + let mut cache: [(usize, Vec, Vec); 4] = std::array::from_fn(|_| (usize::MAX, vec![0.0; w], vec![0.0; w])); + let mut bias = vec![0.0; w]; + for (dy, row) in rows.chunks_mut(w).enumerate() { + let y = band * BAND + dy; + let (top, ys) = &wy[y]; + bias.fill(0.0); + for (dy, yw) in ys.iter().enumerate() { + let cy = top + dy; + let (cached_y, aa, bb) = &mut cache[cy % 4]; + if *cached_y != cy { + let (ar, br) = (a.row(cy), b.row(cy)); + for ((av, bv), (left, xs)) in aa.iter_mut().zip(bb.iter_mut()).zip(&wx) { + *av = 0.0; + *bv = 0.0; + for (dx, xw) in xs.iter().enumerate() { + *av += ar[left + dx] * xw; + *bv += br[left + dx] * xw; + } + } + *cached_y = cy; + } + for (av, ax) in row.iter_mut().zip(aa) { + *av += *ax * yw; + } + for (bv, bx) in bias.iter_mut().zip(bb) { + *bv += *bx * yw; + } + } + for ((v, pv), bv) in row.iter_mut().zip(p.row(y)).zip(&bias) { + *v = *v * pv + bv; + } + } + }); + return q; + } for_rows(&mut q.data, w, |y, row| { for ((v, pv), (av, bv)) in row.iter_mut().zip(p.row(y)).zip(a.row(y).iter().zip(b.row(y))) { *v = av * pv + bv; @@ -191,7 +236,7 @@ fn guided_apply(p: &Plane, a: &Plane, b: &Plane) -> Plane { } /// Fast guided filter (He & Sun 2015): coefficients computed on a `s×` subsampled plane and -/// bilinearly upsampled; the output keeps full-resolution edges. Equivalent to [`guided`] for +/// sampled bilinearly at full resolution; the output keeps full-resolution edges. Equivalent to [`guided`] for /// large windows at a fraction of the cost. pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { use lightcraft_raster::resample::{Filter, resize}; @@ -202,8 +247,7 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane { let (lw, lh) = (p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1)); let lo = resize(p, lw, lh, Filter::Box); let (ma, mb) = guided_coeffs(&lo, sigma / s as f32, eps); - let up = |c: &Plane| resize(c, p.width, p.height, Filter::Bilinear); - guided_apply(p, &up(&ma), &up(&mb)) + guided_apply(p, &ma, &mb) } /// Separable median (`2r+1` px along each row, then each column; clamped edges). It keeps the @@ -758,6 +802,60 @@ mod tests { mod fast_tests { use super::*; + /// The previous fast path, including its two materialized coefficient resizes. + fn resized_reference(p: &Plane, sigma: f32, eps: f32) -> Plane { + use lightcraft_raster::resample::{Filter, resize}; + let s = guided_fast_step(sigma); + if s <= 1 { + return guided(p, sigma, eps); + } + let lo = resize(p, p.width.div_ceil(s).max(1), p.height.div_ceil(s).max(1), Filter::Box); + let (a, b) = guided_coeffs(&lo, sigma / s as f32, eps); + let (a, b) = (resize(&a, p.width, p.height, Filter::Bilinear), resize(&b, p.width, p.height, Filter::Bilinear)); + Plane::from_fn(p.width, p.height, |x, y| a.get(x, y) * p.get(x, y) + b.get(x, y)) + } + + #[test] + fn sampled_coefficients_match_materialized_resizes() { + for (w, h) in [(0, 0), (1, 1), (1, 19), (23, 1), (2, 3), (17, 13), (64, 48), (161, 119)] { + let planes = [ + Plane::filled(w, h, -3.25), + Plane::from_fn(w, h, |x, y| ((x * 7919 + y * 104729) % 97) as f32 / 7.0 - 8.0), + Plane::from_fn(w, h, |x, y| { + if x == w / 2 && y == h / 2 { + 9.0 + } else if x < w / 2 { + -5.0 + } else { + 2.0 + } + }), + ]; + // Step 1 fallback, every small step, Draft's cap, and the maximum step (16). + for sigma in [0.0, 2.99, 5.99, 6.0, 9.0, 12.0, 24.0, 48.0, 200.0] { + for eps in [BASE_EPS, CLARITY_EPS, 0.0001] { + for p in &planes { + let expected = resized_reference(p, sigma, eps); + let actual = guided_fast(p, sigma, eps); + assert_eq!((actual.width, actual.height), (w, h)); + assert_eq!(actual.data, expected.data, "{w}x{h}, sigma {sigma}, eps {eps}"); + } + } + } + } + } + + #[test] + fn sampled_tone_base_keeps_the_median_before_the_guided_filter() { + let p = Plane::from_fn(73, 51, |x, y| if x % 9 == 0 { 3.0 } else { -4.0 + ((x + y) % 7) as f32 * 0.03 }); + for sigma in [6.0, 24.0, 120.0] { + for detail in [0, 1, 3, BASE_DETAIL_MAX] { + let median = median_hv(&p, detail); + assert_eq!(tone_base(&p, sigma, detail).data, resized_reference(&median, sigma, BASE_EPS).data, "{sigma}, {detail}"); + } + } + } + #[test] fn fast_guided_close_to_reference() { let p = Plane::from_fn(160, 120, |x, y| ((x as f32 * 0.13).sin() + (y as f32 * 0.07).cos()) * 2.0 + if x > 80 { 1.5 } else { 0.0 }); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index d7949315e..23eca0ba7 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -75,6 +75,38 @@ fn directional_sliders() { assert!(at("effects.dehaze", -100.0) != base); } +/// Dehaze crushed dark regions next to bright ones to black: the dark channel is a blur, not a +/// patch minimum, so near the boundary it credited the dark pixels with the bright side's veil and +/// the subtraction went past zero (#2504). The veil credited to a pixel is now bounded by its own +/// minimum channel, so a uniform dark region renders uniformly under dehaze. +#[test] +fn dehaze_does_not_crush_dark_regions_next_to_bright_ones() { + // a dark field against a bright hazy sky; the blur bleeds the sky's dark channel ~10 px into + // the field, which is where the blotches formed + let src = Rgb32f::from_fn(240, 160, |x, _| if x < 120 { [0.02, 0.03, 0.012] } else { [0.45, 0.5, 0.55] }); + let mut s = DevelopSettings::default(); + s.effects.dehaze = 80.0; + let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(240, 160)).image; + for ch in 0..3 { + let mut v: Vec = Vec::with_capacity(160 * 100); + for y in 0..160 { + for x in 0..100 { + v.push(r.get(x, y)[ch]); + } + } + v.sort_unstable(); + let mid = v[v.len() / 2] as i32; + // a field rendered all black would pass the comparison below vacuously + assert!(mid > 2, "the dark field itself is crushed in ch {ch}: level {mid}"); + for y in 0..160 { + for x in 0..120 { + let p = r.get(x, y)[ch] as i32; + assert!(p >= mid - 2, "crushed at {x},{y} ch {ch}: {p} vs field level {mid}"); + } + } + } +} + #[test] fn resolution_independence_of_local_contrast() { // Clarity at two preview sizes should give similar results after downscaling. diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index b0fc3de8f..b82a19f45 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -407,3 +407,42 @@ fn auto_mask_strokes_match_the_whole_render() { }; check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); } + +/// The margin the loupe's windows carry around what they show at this size (`region::margin_for`: +/// 4.5 % of the long edge). +const OVERLAY_INSET: usize = W * 45 / 1000; + +/// [`check`] for a diagnostic overlay, with the photo as a raw (Visualize HDR needs one), keeping the +/// loupe's margin in from the window's edges. +fn check_overlay(name: &str, s: &DevelopSettings, overlay: crate::Overlay, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let info = SourceInfo { raw: true, ..Default::default() }; + let full_req = RenderRequest { overlay, ..RenderRequest::fit(W, H) }; + let f = render(&scene(), &info, s, &full_req).image; + let w = render(&scene(), &info, s, &RenderRequest { window: Some(win), ..full_req }).image; + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + assert_ne!(f.data, render(&scene(), &info, s, &RenderRequest::fit(W, H)).image.data, "{name}: the overlay draws something"); + let (max, mean) = compare_inset(&f, &w, win, OVERLAY_INSET); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +// Given a diagnostic overlay (Visualize Spots, a Point Color range, Visualize HDR), a window shows +// the same pixels as the whole-frame render, so the loupe can draw them as a zoom window +#[test] +fn diagnostic_overlays_in_a_window_match_the_full_render() { + use crate::Overlay; + let mut spots = DevelopSettings::default(); + spots.detail.sharpen_amount = 60.0; + // a binary threshold: a pixel at the threshold may flip on a one-level difference underneath + check_overlay("spots", &spots, Overlay::Spots(70), MID, 255, 0.05); + for (x, y) in [(0, 0), (W - 400, H - 300)] { + check_overlay("spots at a corner", &spots, Overlay::Spots(70), PixelWindow { x, y, w: 400, h: 300 }, 255, 0.05); + } + let mut pc = DevelopSettings::default(); + pc.point_colors.push(lightcraft_develop::PointColor { lum: 0.6, chroma: 0.08, hue: 60.0, hue_shift: 40.0, ..Default::default() }); + check_overlay("point colour range", &pc, Overlay::PointColorRange(0), MID, 1, 0.1); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + hdr.hdr.max_ev = 3.0; + hdr.light.exposure = 1.5; + check_overlay("hdr range", &hdr, Overlay::HdrRange, MID, 1, 0.1); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1ae33a03d..5a1e214dd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -2,7 +2,7 @@ name = "lightcraft-ui-egui" version.workspace = true edition.workspace = true -rust-version = "1.95" +rust-version.workspace = true license.workspace = true description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)" diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 8a50d38f1..1eb882f09 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -253,6 +253,7 @@ "new, inside {parent}": "nové, pod {parent}", "Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.", + "Inside: {names}": "Uvnitř: {names}", " in {g} group{}": " ve skupinách: {g}{:.0}", " · kept, not applied: {}": " · zachováno, nepoužito: {}", " — kept but not applied: {}": " — zachováno, ale nepoužito: {}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Procházení složek – složky: {folders}, soubory: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí.", + "Drop to review {n} item{} for import": "Pusťte pro kontrolu před importem (položek: {n}){:.0}", + "Drop to import {n} item{}": "Pusťte pro import (položek: {n}){:.0}", + "Drop to import {n} preset{}": "Pusťte pro import přednastavení: {n}{:.0}", + "Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}", + "Added {n} photo{}": "Přidáno fotografií: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?", + "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})" } diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index e3a98569f..16095a43e 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -374,7 +374,6 @@ "Clear Filters": "Zrušit filtry", "Clear Guides": "Vymazat vodítka", "Clear History": "Vymazat historii", - "Clear Preview Cache": "Vymazat mezipaměť náhledů", "Clear Quick Collection": "Vyprázdnit rychlou kolekci", "Clear Recent Libraries": "Vymazat nedávné knihovny", "Clear Reference": "Zrušit referenční fotografii", @@ -425,7 +424,6 @@ "Company": "Společnost", "Compare": "Porovnat", "Compare (Shift+C)": "Porovnat (Shift+C)", - "Compare Before and After": "Porovnat před a po", "Compare needs at least two photos": "Porovnání vyžaduje alespoň dvě fotografie", "Component options": "Možnosti součásti", "Compression": "Komprese", @@ -1585,7 +1583,6 @@ "Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Inteligentní alba se aktualizují automaticky: změňte jejich pravidla nebo použijte Fotografie → Odstranit fotografii", "LightCraft stopped": "LightCraft se neočekávaně ukončil", - "Show Focus Point": "Zobrazit zaostřovací bod", "Zoom to Focus Point": "Přiblížit na zaostřovací bod", "Focus": "Zaostření", "Focus point": "Zaostřovací bod", @@ -1754,18 +1751,13 @@ "Short Edge": "Kratší hrana", "Show": "Zobrazit", "Show All": "Zobrazit vše", - "Show Clipping": "Zobrazit ořezání tónů", "Show Deghost Overlay": "Zobrazit překrytí odstranění duchů", "Show Hidden Local Locations": "Zobrazit skrytá místní umístění", "Show Luminance Map": "Zobrazit mapu jasu", "Show Mask": "Zobrazit masku", - "Show Mask Overlay": "Zobrazit překrytí masky", - "Show All Mask Overlays": "Zobrazit všechna překrytí masek", "Show all overlays": "Zobrazit všechna překrytí", - "Show Mask Pins": "Zobrazit body masek", "Show Original": "Zobrazit originál", "Show Person": "Zobrazit osobu", - "Show Photo Counts": "Zobrazit počty fotografií", "Show Photos with Keyword": "Zobrazit fotografie s klíčovým slovem", "Show Pins": "Zobrazit body", "Show Sidebar Section": "Zobrazit sekci postranního panelu", @@ -2394,5 +2386,24 @@ "End Slideshow": "Ukončit prezentaci", "Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…", "Manage Effect Presets…": "Spravovat přednastavení efektů…", - "Export Effect Presets…": "Exportovat přednastavení efektů…" + "Export Effect Presets…": "Exportovat přednastavení efektů…", + "Before/After Side by Side": "Před/Po vedle sebe", + "Clipping": "Ořezání tónů", + "Mask Overlay": "Překrytí masky", + "All Mask Overlays": "Všechna překrytí masek", + "Mask Pins": "Body masek", + "Photo Counts": "Počty fotografií", + "Focus Point": "Bod zaostření", + "Layout Image": "Obrázek rozvržení", + "Detail View": "Zobrazení detailu", + "Edit Panel": "Panel Úpravy", + "Close the open dialog, then drop the files again": "Zavřete otevřené okno, pak soubory pusťte znovu", + "Close the open dialog to drop files here": "Zavřete otevřené okno, aby sem šlo soubory pustit", + "Drop to open the face model": "Pusťte pro otevření modelu obličejů", + "Drop to import": "Pusťte pro import", + "Dropped Files": "Přetažené soubory", + "Import dropped files without review": "Importovat přetažené soubory bez kontroly", + "An import is running": "Import už běží", + "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí", + "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky" } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index af29f7134..0aeb0720c 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.", "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…", + "Inside: {names}": "Darin: {names}", "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?", "There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?", "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben", "Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?", "Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Drop to review {n} item{} for import": "Ablegen, um vor dem Import zu prüfen (Objekte: {n}){:.0}", + "Drop to import {n} item{}": "Ablegen zum Importieren (Objekte: {n}){:.0}", + "Drop to import {n} preset{}": "Ablegen zum Importieren von Vorgaben: {n}{:.0}", + "Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}", + "Added {n} photo{}": "{n} Foto{} hinzugefügt", + "Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?", + "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index cfed522cf..27fc7b0dd 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -235,7 +235,6 @@ "Clear Filters": "Filter löschen", "Clear Guides": "Hilfslinien löschen", "Clear History": "Verlauf löschen", - "Clear Preview Cache": "Vorschau-Cache leeren", "Clear Quick Collection": "Schnellsammlung leeren", "Clear Reference": "Referenz entfernen", "Clear all": "Alles löschen", @@ -289,7 +288,6 @@ "Company": "Unternehmen", "Compare": "Vergleichen", "Compare (Shift+C)": "Vergleichen (Umschalt+C)", - "Compare Before and After": "Vorher und Nachher vergleichen", "Compare needs at least two photos": "Zum Vergleichen sind mindestens zwei Fotos erforderlich", "Component options": "Komponentenoptionen", "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimierte Nikon-NEFs, verlustfrei oder verlustbehaftet mit 12 oder 14 Bit, etwa D3200, D5100, D7000, D750, D850 und Z 50,", @@ -1207,6 +1205,9 @@ "Refresh View": "Ansicht aktualisieren", "Reject": "Ablehnen", "Reject below focus": "Unter Schärfegrenzwert ablehnen", + "Burst: shots at most this far apart": "Serie: Aufnahmen höchstens so weit auseinander", + "Burst: how alike (loose … strict)": "Serie: wie ähnlich (locker … streng)", + "Lower it when a panning or moving burst splits into several groups.": "Senken, wenn eine Mitzieher- oder Bewegungsserie in mehrere Gruppen zerfällt.", "Rejected": "Abgelehnt", "Rejects": "Abgelehnte Fotos", "Reliability": "Zuverlässigkeit", @@ -1408,17 +1409,12 @@ "Short Edge": "Kurze Kante", "Show": "Anzeigen", "Show All": "Alle einblenden", - "Show Clipping": "Beschnitt anzeigen", "Show Deghost Overlay": "Geisterbildüberlagerung anzeigen", "Show Hidden Local Locations": "Ausgeblendete lokale Orte anzeigen", "Show Luminance Map": "Luminanzkarte anzeigen", "Show Mask": "Maske anzeigen", - "Show Mask Overlay": "Maskenüberlagerung anzeigen", - "Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen", "Show all overlays": "Alle Überlagerungen anzeigen", - "Show Mask Pins": "Maskenpins anzeigen", "Show Original": "Original anzeigen", - "Show Photo Counts": "Fotoanzahl anzeigen", "Show Photos with Keyword": "Fotos mit Stichwort anzeigen", "Show Pins": "Pins anzeigen", "Show Source": "Quelle anzeigen", @@ -2316,7 +2312,6 @@ "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändere ihre Regeln oder verwende Foto → Foto löschen", "LightCraft stopped": "LightCraft wurde beendet", - "Show Focus Point": "Fokuspunkt anzeigen", "Zoom to Focus Point": "Auf Fokuspunkt zoomen", "Focus": "Fokus", "Focus point": "Fokuspunkt", @@ -3423,5 +3418,24 @@ "favourites across restarts (issue #328).": "über Neustarts hinweg Favoriten (Issue #328).", "Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "Optionales entferntes SAM 3: Der native Editor bleibt lokal, und Objekt-, Beschreibungs- und Detail-", "inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.", - "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference)." + "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).", + "Before/After Side by Side": "Vorher/Nachher nebeneinander", + "Clipping": "Tonwertbeschneidung", + "Mask Overlay": "Maskenüberlagerung", + "All Mask Overlays": "Alle Maskenüberlagerungen", + "Mask Pins": "Maskenpins", + "Photo Counts": "Fotoanzahl", + "Focus Point": "Fokuspunkt", + "Layout Image": "Layoutbild", + "Detail View": "Detailansicht", + "Edit Panel": "Bedienfeld „Bearbeiten“", + "Close the open dialog, then drop the files again": "Schließe den geöffneten Dialog und lege die Dateien dann erneut ab", + "Close the open dialog to drop files here": "Schließe den geöffneten Dialog, um hier Dateien abzulegen", + "Drop to open the face model": "Ablegen, um das Gesichtsmodell zu öffnen", + "Drop to import": "Ablegen zum Importieren", + "Dropped Files": "Abgelegte Dateien", + "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren", + "An import is running": "Ein Import läuft", + "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist", + "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index fd1540169..33e5f52fc 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.", "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…", + "Inside: {names}": "Dentro: {names}", "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?", "There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?", "Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”", "Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?", "Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.", + "Drop to review {n} item{} for import": "Suelta para revisar {n} elemento{} antes de importar", + "Drop to import {n} item{}": "Suelta para importar {n} elemento{}", + "Drop to import {n} preset{}": "Suelta para importar {n} ajuste{0} preestablecido{0}", + "Could not read {name}: {e}": "No se pudo leer {name}: {e}", + "Added {n} photo{}": "{n} foto{0} añadida{0}", + "Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 46965c778..af22570ef 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -228,7 +228,6 @@ "Clear Filters": "Borrar filtros", "Clear Guides": "Borrar guías", "Clear History": "Borrar historial", - "Clear Preview Cache": "Vaciar caché de vistas previas", "Clear Quick Collection": "Borrar colección rápida", "Clear Reference": "Borrar referencia", "Clear all": "Borrar todo", @@ -279,7 +278,7 @@ "Company": "Empresa", "Compare": "Comparar", "Compare (Shift+C)": "Comparar (Mayús+C)", - "Compare Before and After": "Comparar antes y después", + "Before/After Side by Side": "Antes/Después lado a lado", "Compare needs at least two photos": "Comparar requiere al menos dos fotos", "Component options": "Opciones del componente", "Compression": "Compresión", @@ -292,6 +291,8 @@ "Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada", "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar", "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco", + "Dialogs": "Diálogos", + "Close a dialog by clicking outside it": "Cerrar un diálogo al hacer clic fuera de él", "Constrain Crop": "Restringir recorte", "Contact Sheet PDF": "PDF de hoja de contactos", "Continue": "Continuar", @@ -1413,15 +1414,15 @@ "Short Edge": "Lado corto", "Show": "Mostrar", "Show All": "Mostrar todo", - "Show Clipping": "Mostrar recorte de tonos", + "Clipping": "Recorte de tonos", "Show Deghost Overlay": "Mostrar superposición de eliminación de fantasmas", "Show Hidden Local Locations": "Mostrar ubicaciones ocultas de Local", "Show Luminance Map": "Mostrar mapa de luminancia", "Show Mask": "Mostrar máscara", - "Show Mask Overlay": "Mostrar superposición de máscara", - "Show All Mask Overlays": "Mostrar todas las superposiciones de máscara", + "Mask Overlay": "Superposición de máscara", + "All Mask Overlays": "Todas las superposiciones de máscara", "Show all overlays": "Mostrar todas las superposiciones", - "Show Mask Pins": "Mostrar chinchetas de máscara", + "Mask Pins": "Chinchetas de máscara", "Show Original": "Mostrar original", "Show Person": "Mostrar persona", "Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.", @@ -1430,7 +1431,7 @@ "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus", "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,", "Sort Albums A–Z": "Ordenar álbumes A–Z", - "Show Photo Counts": "Mostrar recuento de fotos", + "Photo Counts": "Recuento de fotos", "Show Photos with Keyword": "Mostrar fotos con la palabra clave", "Show Pins": "Mostrar chinchetas", "Show Source": "Mostrar origen", @@ -2314,7 +2315,7 @@ "Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto", "LightCraft stopped": "LightCraft se detuvo", - "Show Focus Point": "Mostrar punto de enfoque", + "Focus Point": "Punto de enfoque", "Zoom to Focus Point": "Zoom al punto de enfoque", "Focus": "Enfoque", "Focus point": "Punto de enfoque", @@ -2342,5 +2343,17 @@ "Checking the GPU…": "Comprobando la GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.", - "Checking the GPU for faces": "Comprobando la GPU para las caras" + "Checking the GPU for faces": "Comprobando la GPU para las caras", + "Layout Image": "Imagen de diseño", + "Detail View": "Vista de detalle", + "Edit Panel": "Panel Editar", + "Close the open dialog, then drop the files again": "Cierra el cuadro de diálogo abierto y vuelve a soltar los archivos", + "Close the open dialog to drop files here": "Cierra el cuadro de diálogo abierto para soltar archivos aquí", + "Drop to open the face model": "Suelta para abrir el modelo de caras", + "Drop to import": "Suelta para importar", + "Dropped Files": "Archivos soltados", + "Import dropped files without review": "Importar los archivos soltados sin revisión", + "An import is running": "Hay una importación en curso", + "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine", + "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos" } diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json index 698080e10..1daab3dab 100644 --- a/crates/ui-egui/locales/fi-formats.json +++ b/crates/ui-egui/locales/fi-formats.json @@ -102,6 +102,7 @@ "Imported {} photo{}": "Tuotuja kuvia: {}{:.0}", "In line for AI Denoise ({ahead} ahead)…": "Jonossa tekoälykohinanpoistoon ({ahead} edellä)…", "Insert at the cursor (also written {})": "Lisää kohdistimen kohtaan (kirjoitetaan myös {})", + "Inside: {names}": "Sisällä: {names}", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se poistetaan {count} kuvalta{:.0}, alla olevat avainsanat mukaan lukien. Kumoa palauttaa sen.", "Keywords of {n} selected photos": "Valittujen kuvien ({n}) avainsanat", "Label Set: {name}": "Merkintäjoukko: {name}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” on jo henkilö. Yhdistäminen tekee kahdesta yhden henkilön; Kumoa erottaa ne uudelleen.", "• {assumption}": "• {assumption}", "… and {more} more": "… ja {more} lisää", - "…and {more} more": "…ja {more} lisää" + "…and {more} more": "…ja {more} lisää", + "Added {n} photo{}": "Lisättyjä kuvia: {n}{:.0}", + "Could not read {name}: {e}": "Tiedostoa {name} ei voitu lukea: {e}", + "Drop to import {n} item{}": "Pudota tuodaksesi (kohteita: {n}){:.0}", + "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}", + "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?", + "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})" } diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index ff1707ac7..5981a09a2 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -306,7 +306,6 @@ "Clear Filters": "Tyhjennä suodattimet", "Clear Guides": "Tyhjennä apuviivat", "Clear History": "Tyhjennä historia", - "Clear Preview Cache": "Tyhjennä esikatselun välimuisti", "Clear Quick Collection": "Tyhjennä pikakokoelma", "Clear Recent Libraries": "Tyhjennä viimeisimmät kirjastot", "Clear Reference": "Tyhjennä vertailukuva", @@ -372,7 +371,6 @@ "Company": "Yritys", "Compare": "Vertaa", "Compare (Shift+C)": "Vertaa (Shift+C)", - "Compare Before and After": "Vertaa ennen ja jälkeen", "Compare needs at least two photos": "Vertailu vaatii vähintään kaksi kuvaa", "Component options": "Komponentin asetukset", "Compression": "Pakkaus", @@ -1822,19 +1820,13 @@ "Show": "Näytä", "Show / hide": "Näytä / piilota", "Show All": "Näytä kaikki", - "Show All Mask Overlays": "Näytä kaikki maskien peittokuvat", - "Show Clipping": "Näytä leikkautuminen", "Show Deghost Overlay": "Näytä haamukuvien poiston peittokuva", - "Show Focus Point": "Näytä tarkennuspiste", "Show Hidden Local Locations": "Näytä piilotetut paikalliset sijainnit", "Show Layout Image": "Näytä asettelukuva", "Show Luminance Map": "Näytä kirkkauskartta", "Show Mask": "Näytä maski", - "Show Mask Overlay": "Näytä maskin peittokuva", - "Show Mask Pins": "Näytä maskin nastat", "Show Original": "Näytä alkuperäinen", "Show Person": "Näytä henkilö", - "Show Photo Counts": "Näytä kuvamäärät", "Show Photos with Keyword": "Näytä kuvat, joilla on avainsana", "Show Pins": "Näytä nastat", "Show Sidebar Section": "Näytä sivupalkin osio", @@ -2311,5 +2303,24 @@ "− harder border · + softer border": "− terävämpi reuna · + pehmeämpi reuna", "≤ at most": "≤ enintään", "≥ at least": "≥ vähintään", - "★ Favorites": "★ Suosikit" + "★ Favorites": "★ Suosikit", + "All Mask Overlays": "Kaikki maskien peittokuvat", + "Before/After Side by Side": "Ennen/jälkeen vierekkäin", + "Clipping": "Leikkautuminen", + "Detail View": "Tarkastelunäkymä", + "Edit Panel": "Muokkauspaneeli", + "Focus Point": "Tarkennuspiste", + "Layout Image": "Asettelukuva", + "Mask Overlay": "Maskin peittokuva", + "Mask Pins": "Maskin nastat", + "Photo Counts": "Kuvamäärät", + "An import is running": "Tuonti on käynnissä", + "An import is running. Drop photos when it has finished": "Tuonti on käynnissä. Pudota kuvat, kun se on valmis", + "Close the open dialog to drop files here": "Sulje avoin valintaikkuna pudottaaksesi tiedostot tähän", + "Close the open dialog, then drop the files again": "Sulje avoin valintaikkuna ja pudota tiedostot sitten uudelleen", + "Drop to import": "Pudota tuodaksesi", + "Drop to open the face model": "Pudota avataksesi kasvomallin", + "Dropped Files": "Pudotetut tiedostot", + "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä", + "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index ae2b77872..294273781 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -51,7 +51,7 @@ "Example: {example}": "Exemple : {example}", "Exported {n} photo{}": "{n} photo{} exportée(s)", "Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}", - "Exported {} point curve preset{}": "{} préréglage{} de courbe de points exporté(s)", + "Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)", "Exported {} preset{}": "{} préréglage{} exporté(s)", "Exporting {} of {total}": "Exportation : {} sur {total}", "Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)", @@ -62,7 +62,7 @@ "Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)", "Import failed: {e}": "Échec de l’importation : {e}", "Import warnings ({n}):\n{warnings}": "Avertissements d’importation ({n}) :\n{warnings}", - "Imported {n} point curve preset{}": "{n} préréglage{} de courbe de points importé(s)", + "Imported {n} point curve preset{}": "{n} préréglage{} de courbe à points importé(s)", "Imported {n} preset{}": "{n} préréglage{} importé(s)", "Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)", @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Supprimer le mot-clé « {name} » ?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Il est retiré de {count} photo{}, avec les mots-clés qu’il contient. Annuler le rétablit.", "Create Keyword Tag Inside “{name}”…": "Créer un mot-clé dans « {name} »…", + "Inside: {names}": "À l’intérieur : {names}", "“{parent}” has a “{name}” already. Merge the two?": "« {parent} » contient déjà « {name} ». Fusionner les deux ?", "There is a “{name}” at the top level already. Merge the two?": "Il existe déjà « {name} » au premier niveau. Fusionner les deux ?", "Added “{keyword}” to {n} photo{}": "« {keyword} » ajouté à {n} photo{}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »", "Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?", "Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{0} déplacée{0} vers « {1} ». Modifier → Annuler les remet en place.", + "Drop to review {n} item{} for import": "Déposez pour vérifier {n} élément{} avant l'import", + "Drop to import {n} item{}": "Déposez pour importer {n} élément{}", + "Drop to import {n} preset{}": "Déposez pour importer {n} préréglage{}", + "Could not read {name}: {e}": "Impossible de lire {name} : {e}", + "Added {n} photo{}": "{n} photo{} ajoutée(s)", + "Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?", + "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 2f22a70c1..33a892ecf 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -26,7 +26,7 @@ "Add Folder to Local": "Ajouter le dossier à Local", "Add Mask": "Ajouter un masque", "Add Object Click": "Ajouter un clic sur l'objet", - "Add Point Color Sample": "Ajouter un échantillon de couleur ponctuelle", + "Add Point Color Sample": "Ajouter un échantillon de point de couleur", "Add Red Eye Correction": "Ajouter une correction des yeux rouges", "Add Remove Spot": "Ajouter une suppression de tache", "Add Selected Photos": "Ajouter les photos sélectionnées", @@ -76,21 +76,21 @@ "Apply Color Label Set": "Appliquer un jeu d'étiquettes de couleur", "Apply Filter Preset": "Appliquer un préréglage de filtre", "Apply Metadata Preset": "Appliquer un préréglage de métadonnées", - "Apply Point Curve Preset": "Appliquer un préréglage de courbe de points", + "Apply Point Curve Preset": "Appliquer un préréglage de courbe à points", "Apply Preset": "Appliquer un préréglage", "Apply Settings JSON": "Appliquer des réglages JSON", "Approved": "Approuvée", - "Aqua": "Aigue-marine", - "Aqua Hue": "Teinte aigue-marine", - "Aqua Luminance": "Luminance aigue-marine", - "Aqua Saturation": "Saturation aigue-marine", + "Aqua": "Turquoise", + "Aqua Hue": "Teinte turquoise", + "Aqua Luminance": "Luminance turquoise", + "Aqua Saturation": "Saturation turquoise", "ArtCraft Website": "Site web d'ArtCraft", "ArtCraft — more creative apps": "ArtCraft : d'autres applications créatives", "As Shot": "Tel quel", "As shot": "Tel quel", "Ascending": "Croissant", "Aspect": "Format", - "Aspect Ratio": "Rapport d'aspect", + "Aspect Ratio": "Ratio de format", "Assisted Culling": "Tri assisté", "Assisted Culling…": "Tri assisté…", "Auto": "Auto", @@ -142,7 +142,7 @@ "Best of Similar Shots": "Meilleure des prises similaires", "Binary assets added": "Ressources binaires ajoutées", "Binary assets removed": "Ressources binaires supprimées", - "Bit depth": "Profondeur de bits", + "Bit depth": "Profondeur de couleur", "Black & White": "Noir et blanc", "Blacks": "Noirs", "Bleach Bypass": "Bleach bypass", @@ -214,7 +214,6 @@ "Clear Filters": "Effacer les filtres", "Clear Guides": "Effacer les repères", "Clear History": "Effacer l'historique", - "Clear Preview Cache": "Vider le cache des aperçus", "Clear Quick Collection": "Vider la collection rapide", "Clear Reference": "Effacer la référence", "Clear all": "Tout effacer", @@ -264,7 +263,7 @@ "Company": "Société", "Compare": "Comparer", "Compare (Shift+C)": "Comparer (Maj+C)", - "Compare Before and After": "Comparer avant et après", + "Before/After Side by Side": "Avant/Après côte à côte", "Compare needs at least two photos": "Comparer nécessite au moins deux photos", "Component options": "Options du composant", "Compression": "Compression", @@ -374,9 +373,9 @@ "Delete Permanently": "Supprimer définitivement", "Delete Photo": "Supprimer la photo", "Delete Photos": "Supprimer les photos", - "Delete Point Color": "Supprimer la couleur ponctuelle", - "Delete Point Color Sample": "Supprimer l'échantillon de couleur ponctuelle", - "Delete Point Curve Preset": "Supprimer le préréglage de courbe de points", + "Delete Point Color": "Supprimer le point de couleur", + "Delete Point Color Sample": "Supprimer l'échantillon de point de couleur", + "Delete Point Curve Preset": "Supprimer le préréglage de courbe à points", "Delete Preset": "Supprimer le préréglage", "Delete Red Eye": "Supprimer les yeux rouges", "Delete Red Eye Correction": "Supprimer la correction des yeux rouges", @@ -470,8 +469,8 @@ "Export As…": "Exporter sous…", "Export Group…": "Exporter le groupe…", "Export Now": "Exporter maintenant", - "Export Point Curve Presets": "Exporter les préréglages de courbe de points", - "Export Point Curve Presets…": "Exporter les préréglages de courbe de points…", + "Export Point Curve Presets": "Exporter les préréglages de courbe à points", + "Export Point Curve Presets…": "Exporter les préréglages de courbe à points…", "Export Presets": "Exporter les préréglages", "Export Presets…": "Exporter les préréglages…", "Export User Presets…": "Exporter les préréglages utilisateur…", @@ -602,12 +601,12 @@ "Grid Info": "Informations de la grille", "Gritty Street": "Rue brute", "Group": "Groupe", - "Group by Date: Automatic": "Regrouper par date : automatique", - "Group by Day": "Regrouper par jour", - "Group by Month": "Regrouper par mois", - "Group by Year": "Regrouper par année", - "Group by date": "Regrouper par date", - "Group into Stack": "Regrouper en pile", + "Group by Date: Automatic": "Grouper par date : automatique", + "Group by Day": "Grouper par jour", + "Group by Month": "Grouper par mois", + "Group by Year": "Grouper par année", + "Group by date": "Grouper par date", + "Group into Stack": "Grouper en pile", "Group name": "Nom du groupe", "Guided": "Guidé", "Guided Upright": "Upright guidé", @@ -664,8 +663,8 @@ "Import Lightroom Catalog…": "Importer un catalogue Lightroom…", "Import Photos": "Importer des photos", "Import Photos…": "Importer des photos…", - "Import Point Curve Presets": "Importer des préréglages de courbe de points", - "Import Point Curve Presets…": "Importer des préréglages de courbe de points…", + "Import Point Curve Presets": "Importer des préréglages de courbe à points", + "Import Point Curve Presets…": "Importer des préréglages de courbe à points…", "Import Presets": "Importer des préréglages", "Import Presets…": "Importer des préréglages…", "Import Profiles": "Importer des profils", @@ -1037,10 +1036,10 @@ "Picks": "Sélectionnées", "Pictures": "Images", "Pixels": "Pixels", - "Point Color": "Couleur ponctuelle", - "Point Curve": "Courbe de points", - "Point Curve Presets": "Préréglages de courbe de points", - "Point curve": "Courbe de points", + "Point Color": "Point de couleur", + "Point Curve": "Courbe à points", + "Point Curve Presets": "Préréglages de courbe à points", + "Point curve": "Courbe à points", "Portrait": "Portrait", "Position": "Position", "Preferences": "Préférences", @@ -1257,8 +1256,8 @@ "Save Metadata Preset": "Enregistrer le préréglage de métadonnées", "Save Metadata Preset…": "Enregistrer le préréglage de métadonnées…", "Save Metadata to File": "Enregistrer les métadonnées dans le fichier", - "Save Point Curve Preset": "Enregistrer le préréglage de courbe de points", - "Save Point Curve…": "Enregistrer la courbe de points…", + "Save Point Curve Preset": "Enregistrer le préréglage de courbe à points", + "Save Point Curve…": "Enregistrer la courbe à points…", "Save as Preset": "Enregistrer comme préréglage", "Save as Set…": "Enregistrer comme jeu…", "Save as Smart Album…": "Enregistrer comme album intelligent…", @@ -1308,7 +1307,7 @@ "Set Image Zoom and Pan": "Définir le zoom et le déplacement de l'image", "Set Keyboard Shortcut": "Définir le raccourci clavier", "Set Mask Adjustments": "Définir les réglages du masque", - "Set Point Curve": "Définir la courbe de points", + "Set Point Curve": "Définir la courbe à points", "Set Profile": "Définir le profil", "Set Rating": "Définir la note", "Set Top of Stack": "Définir le haut de la pile", @@ -1343,17 +1342,17 @@ "Short Edge": "Petit côté", "Show": "Afficher", "Show All": "Tout afficher", - "Show Clipping": "Afficher l'écrêtage", + "Clipping": "Écrêtage", "Show Deghost Overlay": "Afficher l'incrustation de suppression des images fantômes", "Show Hidden Local Locations": "Afficher les emplacements Local masqués", "Show Luminance Map": "Afficher la carte de luminance", "Show Mask": "Afficher le masque", - "Show Mask Overlay": "Afficher l'incrustation du masque", - "Show All Mask Overlays": "Afficher toutes les superpositions de masque", + "Mask Overlay": "Incrustation du masque", + "All Mask Overlays": "Toutes les incrustations de masque", "Show all overlays": "Afficher toutes les superpositions", - "Show Mask Pins": "Afficher les épingles de masque", + "Mask Pins": "Épingles de masque", "Show Original": "Afficher l'original", - "Show Photo Counts": "Afficher le nombre de photos", + "Photo Counts": "Nombre de photos", "Show Photos with Keyword": "Afficher les photos avec le mot-clé", "Show Pins": "Afficher les épingles", "Show Source": "Afficher la source", @@ -1458,7 +1457,7 @@ "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster la courbe à cet endroit", "Teal & Amber": "Bleu-vert et ambre", "Teal & Orange": "Bleu-vert et orange", - "Temp": "Temp.", + "Temp": "Température", "Temperature": "Température", "Template": "Modèle", "Temporary session — your library isn't open, and nothing you do here is saved.": "Session temporaire : votre bibliothèque n'est pas ouverte et rien de ce que vous faites ici n'est enregistré.", @@ -1930,7 +1929,7 @@ "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", "Looking for changes": "Recherche des modifications", - "Arrange Panel": "Disposer le panneau", + "Arrange Panel": "Modifier le panneau", "Close panel": "Fermer le panneau", "Float panel": "Panneau flottant", "Move to group": "Déplacer vers le groupe", @@ -1956,7 +1955,7 @@ "Perceptual": "Perceptif", "Relative": "Relatif", "Simulate Paper & Ink": "Simuler le papier et l'encre", - "Content-Aware": "Contenu pris en compte", + "Content-Aware": "Selon le contenu", "Sidebar Sections": "Sections de la barre latérale", "Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale", "Hide This Section": "Masquer cette section", @@ -2249,7 +2248,7 @@ "Intro screen": "Écran d'introduction", "Ending screen": "Écran de fin", "Shows": "Affiche", - "Colour only": "Couleur seule", + "Colour only": "Couleur unie", "Override colour": "Remplacer la couleur", "Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)", "Duration": "Durée", @@ -2312,7 +2311,7 @@ "Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo", "LightCraft stopped": "LightCraft s’est arrêté", - "Show Focus Point": "Afficher le point de mise au point", + "Focus Point": "Point de mise au point", "Zoom to Focus Point": "Zoomer sur le point de mise au point", "Focus": "Mise au point", "Focus point": "Point de mise au point", @@ -2340,5 +2339,53 @@ "Checking the GPU…": "Vérification du GPU…", "CPU": "Processeur", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", - "Checking the GPU for faces": "Vérification du GPU pour les visages" + "Checking the GPU for faces": "Vérification du GPU pour les visages", + "Layout Image": "Image de mise en page", + "Detail View": "Vue de détail", + "Edit Panel": "Panneau Modifier", + "Close the open dialog, then drop the files again": "Fermez la boîte de dialogue ouverte, puis déposez à nouveau les fichiers", + "Close the open dialog to drop files here": "Fermez la boîte de dialogue ouverte pour déposer des fichiers ici", + "Drop to open the face model": "Déposez pour ouvrir le modèle de visages", + "Drop to import": "Déposez pour importer", + "Dropped Files": "Fichiers déposés", + "Import dropped files without review": "Importer les fichiers déposés sans vérification", + "An import is running": "Une importation est en cours", + "An import is running. Drop photos when it has finished": "Une importation est en cours. Déposez les photos quand elle sera terminée", + "Finish Lightroom catalog import before adding photos": "Terminez l'importation du catalogue Lightroom avant d'ajouter des photos", + "The library is empty: the catalog's photos, collections and edits become its first.": "La bibliothèque est vide : les photos, collections et retouches du catalogue seront les premières.", + "The catalog's photos, collections and edits join the ones already in this library.": "Les photos, collections et retouches du catalogue rejoignent celles déjà présentes dans cette bibliothèque.", + "Create a new library": "Créer une nouvelle bibliothèque", + "LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.": "LightCraft ouvre la nouvelle bibliothèque, puis y importe le catalogue. Son dossier doit être nouveau ou vide.", + "Don't ask again (always merge into the open library)": "Ne plus demander (toujours fusionner avec la bibliothèque ouverte)", + "To be asked again: Settings → General → When importing a Lightroom catalog.": "Pour que la question revienne : Réglages → Général → À l’importation d’un catalogue Lightroom.", + "Create Library and Import": "Créer la bibliothèque et importer", + "Another library was opened since this window appeared: nothing was imported": "Une autre bibliothèque a été ouverte depuis l’apparition de cette fenêtre : rien n’a été importé", + "When importing a Lightroom catalog": "À l’importation d’un catalogue Lightroom", + "Ask where to import it": "Demander où l’importer", + "Always merge into the open library": "Toujours fusionner avec la bibliothèque ouverte", + "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque.", + "At launch": "Au lancement", + "Open the last library": "Ouvrir la dernière bibliothèque", + "Ask me": "Me demander", + "Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.": "« Me demander » affiche vos bibliothèques récentes au lancement. Maintenir Alt (Option sur Mac) pendant que la fenêtre apparaît le demande une fois.", + "Open a Library": "Ouvrir une bibliothèque", + "Choose the library to open.": "Choisissez la bibliothèque à ouvrir.", + "No recent libraries. Open one from a folder, or make a new one.": "Aucune bibliothèque récente. Ouvrez-en une depuis un dossier, ou créez-en une nouvelle.", + "Remove from List": "Retirer de la liste", + "The library itself stays where it is.": "La bibliothèque elle-même reste où elle est.", + "Open": "Ouvrir", + "Open Another…": "Ouvrir une autre…", + "Open now": "Ouverte en ce moment", + "Last opened": "Ouverte le", + "Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté", + "There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft", + "This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque", + "This folder can't be read:": "Ce dossier ne peut pas être lu :", + "The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :", + "Not answering yet: the drive may be asleep or offline": "Pas encore de réponse : le disque est peut-être en veille ou hors ligne", + "This library couldn't be checked": "Impossible de vérifier cette bibliothèque", + "Checking recent libraries": "Vérification des bibliothèques récentes", + "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte", + "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages", + "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :" } diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json index b3887f917..7fc325522 100644 --- a/crates/ui-egui/locales/id-formats.json +++ b/crates/ui-egui/locales/id-formats.json @@ -297,5 +297,13 @@ "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})", "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.", - "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}" + "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}", + "Added {n} photo{}": "{n} foto ditambahkan{:.0}", + "Could not read {name}: {e}": "Tidak dapat membaca {name}: {e}", + "Drop to import {n} item{}": "Jatuhkan untuk mengimpor {n} item{:.0}", + "Drop to import {n} preset{}": "Jatuhkan untuk mengimpor {n} preset{:.0}", + "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}", + "Inside: {names}": "Di dalam: {names}", + "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?", + "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})" } diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index aefe04417..abb6a732e 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -308,7 +308,6 @@ "Clear Filters": "Hapus filter", "Clear Guides": "Hapus garis panduan", "Clear History": "Hapus riwayat", - "Clear Preview Cache": "Kosongkan cache pratinjau", "Clear Print Layout": "Kosongkan tata letak cetak", "Clear Quick Collection": "Kosongkan koleksi cepat", "Clear Recent Libraries": "Hapus daftar pustaka terbaru", @@ -372,7 +371,6 @@ "Company": "Perusahaan", "Compare": "Bandingkan", "Compare (Shift+C)": "Bandingkan (Shift+C)", - "Compare Before and After": "Bandingkan Sebelum dan Sesudah", "Compare needs at least two photos": "Perlu minimal dua foto untuk dibandingkan", "Component options": "Opsi komponen", "Compression": "Kompresi", @@ -1817,19 +1815,13 @@ "Short Edge": "Sisi pendek", "Show": "Tampilkan", "Show All": "Tampilkan semua", - "Show All Mask Overlays": "Tampilkan semua overlay masker", - "Show Clipping": "Tampilkan clipping", "Show Deghost Overlay": "Tampilkan overlay deghost", - "Show Focus Point": "Tampilkan titik fokus", "Show Hidden Local Locations": "Tampilkan lokasi Lokal yang disembunyikan", "Show Layout Image": "Tampilkan gambar tata letak", "Show Luminance Map": "Tampilkan peta luminans", "Show Mask": "Tampilkan masker", - "Show Mask Overlay": "Tampilkan overlay masker", - "Show Mask Pins": "Tampilkan pin masker", "Show Original": "Tampilkan yang asli", "Show Person": "Tampilkan orang", - "Show Photo Counts": "Tampilkan jumlah foto", "Show Photos with Keyword": "Tampilkan foto dengan kata kunci ini", "Show Pins": "Tampilkan pin", "Show Sidebar Section": "Tampilkan bagian bilah samping", @@ -2325,5 +2317,24 @@ "≤ at most": "≤ maksimal", "≥ at least": "≥ minimal", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "All Mask Overlays": "Semua overlay masker", + "Before/After Side by Side": "Sebelum/Sesudah berdampingan", + "Clipping": "Clipping", + "Detail View": "Tampilan Detail", + "Edit Panel": "Panel Edit", + "Focus Point": "Titik fokus", + "Layout Image": "Gambar tata letak", + "Mask Overlay": "Overlay masker", + "Mask Pins": "Pin masker", + "Photo Counts": "Jumlah foto", + "An import is running": "Impor sedang berjalan", + "An import is running. Drop photos when it has finished": "Impor sedang berjalan. Jatuhkan foto setelah selesai", + "Close the open dialog to drop files here": "Tutup dialog yang terbuka untuk menjatuhkan file di sini", + "Close the open dialog, then drop the files again": "Tutup dialog yang terbuka, lalu jatuhkan file lagi", + "Drop to import": "Jatuhkan untuk mengimpor", + "Drop to open the face model": "Jatuhkan untuk membuka model wajah", + "Dropped Files": "File yang dijatuhkan", + "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto", + "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan" } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index ccd01e6a7..7efae90e9 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -283,6 +283,7 @@ "Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?", "{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Inside: {names}": "Al suo interno: {names}", "Template preview: {name}": "Anteprima del modello: {name}", "{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}", "Applied {name}": "{name} applicato", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}", "Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.", - "Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}" + "Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}", + "Drop to review {n} item{} for import": "Rilascia per rivedere prima dell'importazione (elementi: {n}){:.0}", + "Drop to import {n} item{}": "Rilascia per importare (elementi: {n}){:.0}", + "Drop to import {n} preset{}": "Rilascia per importare predefiniti: {n}{:.0}", + "Could not read {name}: {e}": "Impossibile leggere {name}: {e}", + "Added {n} photo{}": "Foto aggiunte: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})" } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 759fe53d0..a75448fbb 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -215,7 +215,6 @@ "Clear Filters": "Cancella filtri", "Clear Guides": "Cancella guide", "Clear History": "Cancella cronologia", - "Clear Preview Cache": "Svuota cache delle anteprime", "Clear Quick Collection": "Svuota la raccolta rapida", "Clear Reference": "Cancella riferimento", "Clear all": "Cancella tutto", @@ -266,7 +265,6 @@ "Company": "Azienda", "Compare": "Confronta", "Compare (Shift+C)": "Confronta (Maiusc+C)", - "Compare Before and After": "Confronta prima e dopo", "Compare needs at least two photos": "Per confrontare servono almeno due foto", "Component options": "Opzioni del componente", "Compression": "Compressione", @@ -1229,7 +1227,6 @@ "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Gli album smart si aggiornano da soli: cambia le loro regole, oppure usa Foto → Elimina foto", "LightCraft stopped": "LightCraft si è chiuso", - "Show Focus Point": "Mostra punto di messa a fuoco", "Zoom to Focus Point": "Zoom sul punto di messa a fuoco", "Focus": "Messa a fuoco", "Focus point": "Punto di messa a fuoco", @@ -1381,17 +1378,12 @@ "Short Edge": "Lato corto", "Show": "Mostra", "Show All": "Mostra tutto", - "Show Clipping": "Mostra tagli", "Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi", "Show Hidden Local Locations": "Mostra le posizioni locali nascoste", "Show Luminance Map": "Mostra mappa di luminanza", "Show Mask": "Mostra maschera", - "Show Mask Overlay": "Mostra sovrapposizione maschera", - "Show All Mask Overlays": "Mostra tutte le sovrapposizioni maschera", "Show all overlays": "Mostra tutte le sovrapposizioni", - "Show Mask Pins": "Mostra i punti delle maschere", "Show Original": "Mostra originale", - "Show Photo Counts": "Mostra il numero di foto", "Show Photos with Keyword": "Mostra le foto con la parola chiave", "Show Pins": "Mostra i punti", "Show Source": "Mostra origine", @@ -2345,5 +2337,24 @@ "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", "Camera & Lens": "Fotocamera e obiettivo", - "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2." + "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.", + "Before/After Side by Side": "Prima/Dopo affiancati", + "Clipping": "Tagli tonali", + "Mask Overlay": "Sovrapposizione maschera", + "Mask Pins": "Punti delle maschere", + "Photo Counts": "Numero di foto", + "Focus Point": "Punto di messa a fuoco", + "All Mask Overlays": "Tutte le sovrapposizioni maschera", + "Layout Image": "Immagine di layout", + "Detail View": "Vista dettaglio", + "Edit Panel": "Pannello Modifica", + "Close the open dialog, then drop the files again": "Chiudi la finestra di dialogo aperta, poi rilascia di nuovo i file", + "Close the open dialog to drop files here": "Chiudi la finestra di dialogo aperta per rilasciare qui i file", + "Drop to open the face model": "Rilascia per aprire il modello dei volti", + "Drop to import": "Rilascia per importare", + "Dropped Files": "File rilasciati", + "Import dropped files without review": "Importa i file rilasciati senza revisione", + "An import is running": "Un'importazione è in corso", + "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita", + "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ce56500b5..99c14bf16 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}", "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…", + "Inside: {names}": "この中: {names}", "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?", "There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?", "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません", "Listing folders — folders: {folders}, files: {files}": "フォルダーを一覧中 — フォルダー:{folders}、ファイル:{files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})", + "Drop to review {n} item{} for import": "ドロップして {n} 件を確認してから読み込み{:.0}", + "Drop to import {n} item{}": "ドロップして {n} 件を読み込み{:.0}", + "Drop to import {n} preset{}": "ドロップして {n} 件のプリセットを読み込み{:.0}", + "Could not read {name}: {e}": "{name} を読み取れませんでした: {e}", + "Added {n} photo{}": "{n}枚を追加しました{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか?", + "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合(写真{count}枚){:.0}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4c55ee494..c3f11e6e5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -215,7 +215,6 @@ "Clear Filters": "フィルターを解除", "Clear Guides": "ガイドを消去", "Clear History": "履歴を消去", - "Clear Preview Cache": "プレビューキャッシュを消去", "Clear Quick Collection": "クイックコレクションを空にする", "Clear Reference": "参照写真を解除", "Clear all": "すべて解除", @@ -260,7 +259,7 @@ "Company": "企業", "Compare": "比較", "Compare (Shift+C)": "比較(⇧C)", - "Compare Before and After": "編集前と編集後を比較", + "Before/After Side by Side": "編集前/編集後の左右表示", "Compare needs at least two photos": "比較には2枚以上の写真が必要です", "Component options": "構成要素の設定", "Compression": "圧縮", @@ -1289,17 +1288,17 @@ "Short Edge": "短辺", "Show": "表示", "Show All": "すべて表示", - "Show Clipping": "白飛び・黒つぶれを表示", + "Clipping": "白飛び・黒つぶれ", "Show Deghost Overlay": "ゴースト除去範囲を表示", "Show Hidden Local Locations": "非表示にしたローカルの場所を表示", "Show Luminance Map": "輝度マップを表示", "Show Mask": "マスクを表示", - "Show Mask Overlay": "マスクの重ね表示", - "Show All Mask Overlays": "すべてのマスクオーバーレイを表示", + "Mask Overlay": "マスクの重ね表示", + "All Mask Overlays": "すべてのマスクの重ね表示", "Show all overlays": "すべてのオーバーレイを表示", - "Show Mask Pins": "マスクのピンを表示", + "Mask Pins": "マスクのピン", "Show Original": "元の写真を表示", - "Show Photo Counts": "写真の枚数を表示", + "Photo Counts": "写真の枚数", "Show Photos with Keyword": "このキーワードの写真を表示", "Show Pins": "ピンを表示", "Show Source": "コピー元を表示", @@ -2314,7 +2313,7 @@ "Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください", "LightCraft stopped": "LightCraftが終了しました", - "Show Focus Point": "フォーカスポイントを表示", + "Focus Point": "フォーカスポイント", "Zoom to Focus Point": "フォーカスポイントにズーム", "Focus": "フォーカス", "Focus point": "フォーカスポイント", @@ -2342,5 +2341,17 @@ "Checking the GPU…": "GPU を確認中…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。", - "Checking the GPU for faces": "顔用の GPU を確認中" + "Checking the GPU for faces": "顔用の GPU を確認中", + "Layout Image": "レイアウト画像", + "Detail View": "詳細表示", + "Edit Panel": "編集パネル", + "Close the open dialog, then drop the files again": "開いているダイアログを閉じてから、もう一度ファイルをドロップしてください", + "Close the open dialog to drop files here": "ファイルをここにドロップするには、開いているダイアログを閉じてください", + "Drop to open the face model": "ドロップして顔モデルを開く", + "Drop to import": "ドロップして読み込み", + "Dropped Files": "ドロップしたファイル", + "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む", + "An import is running": "読み込み中です", + "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください", + "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください" } diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index c3b48ef62..460368261 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -268,6 +268,7 @@ "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Inside: {names}": "შიგნით: {names}", "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", "Saved template {name}": "Saved template {name}", "Skipped {problem}": "Skipped {problem}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", "Listing folders — folders: {folders}, files: {files}": "საქაღალდეების ჩამოთვლა — საქაღალდეები: {folders}, ფაილები: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.", + "Drop to review {n} item{} for import": "ჩააგდეთ იმპორტამდე გადასახედად (ელემენტები: {n}){:.0}", + "Drop to import {n} item{}": "ჩააგდეთ იმპორტისთვის (ელემენტები: {n}){:.0}", + "Drop to import {n} preset{}": "ჩააგდეთ პრესეტების იმპორტისთვის: {n}{:.0}", + "Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}", + "Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?", + "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}" } diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 066f2ecd2..13fd48d3c 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -373,7 +373,6 @@ "Clear Filters": "ფილტრების გასუფთავება", "Clear Guides": "დამხმარე ხაზების გასუფთავება", "Clear History": "ისტორიის გასუფთავება", - "Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება", "Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება", "Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება", "Clear Reference": "ეტალონის გასუფთავება", @@ -429,7 +428,7 @@ "Company": "კომპანია", "Compare": "შედარება", "Compare (Shift+C)": "შედარება (Shift+C)", - "Compare Before and After": "მანამდე და შემდეგ შედარება", + "Before/After Side by Side": "მანამდე/შემდეგ გვერდიგვერდ", "Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ", "Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება", "Component options": "კომპონენტის პარამეტრები", @@ -1902,17 +1901,17 @@ "Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და", "Show": "ჩვენება", "Show All": "ყველას ჩვენება", - "Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება", - "Show Clipping": "ჩამოჭრილი ტონების ჩვენება", + "All Mask Overlays": "ნიღბების ყველა ზედდება", + "Clipping": "ჩამოჭრილი ტონები", "Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება", "Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება", "Show Luminance Map": "სიკაშკაშის რუკის ჩვენება", "Show Mask": "ნიღბის ჩვენება", - "Show Mask Overlay": "ნიღბის ზედდების ჩვენება", - "Show Mask Pins": "ნიღბის მარკერების ჩვენება", + "Mask Overlay": "ნიღბის ზედდება", + "Mask Pins": "ნიღბის მარკერები", "Show Original": "ორიგინალის ჩვენება", "Show Person": "ადამიანის ჩვენება", - "Show Photo Counts": "ფოტოების რაოდენობის ჩვენება", + "Photo Counts": "ფოტოების რაოდენობა", "Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება", "Show Pins": "მარკერების ჩვენება", "Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება", @@ -2719,5 +2718,18 @@ "≥ at least": "≥ არანაკლებ", "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "Focus Point": "ფოკუსის წერტილი", + "Layout Image": "განლაგების სურათი", + "Detail View": "დეტალური ხედი", + "Edit Panel": "რედაქტირების პანელი", + "Close the open dialog, then drop the files again": "დახურეთ გახსნილი დიალოგი და ფაილები ხელახლა ჩააგდეთ", + "Close the open dialog to drop files here": "ფაილების აქ ჩასაგდებად დახურეთ გახსნილი დიალოგი", + "Drop to open the face model": "ჩააგდეთ სახეების მოდელის გასახსნელად", + "Drop to import": "ჩააგდეთ იმპორტისთვის", + "Dropped Files": "ჩაგდებული ფაილები", + "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე", + "An import is running": "იმპორტი მიმდინარეობს", + "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება", + "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი" } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index e3ce316a0..20c7f840d 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -5,17 +5,17 @@ " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", " · {} duplicate{} skipped": " · pominięte duplikaty: {}{:.0}", - " · {} failed: {}": " · {} nieudanych: {}", - " · {} moved": " · {} przeniesionych", + " · {} failed: {}": " · nieudane ({}): {}", + " · {} moved": " · przeniesione: {}", " · {} not readable": " · {} nie można odczytać", " · {} original{} left at the source": " · oryginały pozostawione w źródle: {}{:.0}", - " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", + " · {} restored from Recently Deleted": " · przywrócone z Ostatnio usuniętych: {}", " — not carried over: {}{}": " — nie przeniesiono: {}{}", "+{} more": "+{} więcej", ", {failed} file{} not readable": ", nieczytelne pliki: {failed}{:.0}", "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", "; {outside} outside its time range": "; {outside} poza zakresem dat", - "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, zatwierdzenia: {}, wiersze: +{} / −{} (Δ {}), zasoby binarne: +{} / −{}, {} – {}", "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", "Add {} Photo{}": "Dodaj zdjęcia: {}{:.0}", "Add {} Photo{}, restore {}": "Dodaj zdjęcia: {}{:.0}, przywróć: {}", @@ -29,12 +29,12 @@ "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece — zaznaczone zdjęcia we Wszystkich zdjęciach: {}{:.0}", "Auto ({})": "Automatycznie ({})", "Auto Import: added {} photo{}": "Automatyczny import — dodane zdjęcia: {}{:.0}", - "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", + "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany obejmują zdjęcia ({n})", "Automatic ({} MB)": "Automatycznie ({} MB)", "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", "Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})", "Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})", - "Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…", + "Building previews for {} photos…": "Tworzenie podglądów (zdjęcia: {})…", "Click {}:1": "Kliknij {}:1", "Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}", "Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych", @@ -42,7 +42,7 @@ "Decoder: {reason}": "Dekoder: {reason}", "Delete \"{label}\"": "Usuń „{label}”", "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", - "Delete {n} Photos": "Usuń {n} zdjęć", + "Delete {n} Photos": "Usuń zdjęcia ({n})", "Delete “{current}”": "Usuń „{current}”", "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", "Done ({what}): {done} photo{plural}": "Gotowe ({what}) — zdjęcia: {done}{plural:.0}", @@ -69,10 +69,10 @@ "Label Set: {name}": "Zestaw etykiet: {name}", "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących", - "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć", - "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom — zdjęcia: {photos}, kolekcje: {collections}, brakujące: {missing}", + "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony — dodane zdjęcia: {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony — dodane zdjęcia: {imported}, błędy: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony — dodane zdjęcia: {imported}; ostrzeżenie indeksu: {warning}", "Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}", "Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}", "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.", @@ -89,7 +89,7 @@ "Profile: {}": "Profil: {}", "Proof Copy ({})": "Kopia próbna ({})", "Proof Preview · {}": "Podgląd próbny · {}", - "Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć", + "Quick Develop · {n} photos": "Szybka obróbka · zdjęcia: {n}", "Quick Develop: {name}": "Szybka obróbka: {name}", "Rated {}": "Ocena: {}", "Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}", @@ -100,7 +100,7 @@ "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", "Removed from {}": "Usunięto z {}", "Removed {what} from {name}": "Usunięto {what} z {name}", - "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", + "Rename {n} Photos…": "Zmień nazwy zdjęć ({n})…", "Rename “{name}”": "Zmień nazwę „{name}”", "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na zdjęciach ({n}){:.0} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", @@ -122,7 +122,7 @@ "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", "Version: {}": "Wersja: {}", "Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}", - "Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…", + "Working on {what} for {} photos…": "Przetwarzanie: {what} (zdjęcia: {})…", "none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}", "the {n} selected photos": "{n} zaznaczonych zdjęć", "the {} photos in view": "{} zdjęć w bieżącym widoku", @@ -132,12 +132,12 @@ "{label} Grading": "Gradacja: {label}", "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", "{label} {index} of {n}": "{label} {index} z {n}", - "{label}, {n} photos": "{label}, {n} zdjęć", + "{label}, {n} photos": "{label}, zdjęcia: {n}", "{matching} of {t} photos": "{matching} z {t} zdjęć", - "{matching} photos": "{matching} pasujących zdjęć", + "{matching} photos": "Pasujące zdjęcia: {matching}", "{mode} spot {} of {}": "{mode}: plamka {} z {}", - "{month}": "{month}", - "{month} {year}": "{month} {year}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}.{year}", "{msg} ({failed} failed)": "{msg} (nieudanych: {failed})", "{name} Label": "Etykieta {name}", "{name} Off": "{name}: wyłączone", @@ -148,8 +148,8 @@ "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){0:.0}: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu teraz oznacza ich utratę.{1:.0}", "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){:.0}: {e} — LightCraft ponowi próbę", "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", - "{n} photos": "{n} zdjęć", - "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", + "{n} photos": "zdjęcia ({n})", + "{n} photos in {} are hidden by the filters above": "Zdjęcia w {} ukryte przez powyższe filtry: {n}", "{n} photo{}": "Zdjęcia: {n}{:.0}", "{n} photo{} match · updates automatically as photos change": "Pasujące zdjęcia: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", "{n} photo{} now · updates automatically as photos change": "Zdjęcia teraz: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", @@ -158,25 +158,25 @@ "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} (zdjęcia: {n}){:.0}", "{weekday}, {day}": "{weekday}, {day}", - "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", + "{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}", "{what} merge added": "Dodano po scaleniu: {what}", "{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}", "{year}": "{year}", "{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}", "{} and higher": "{} i nowsze", - "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", - "{} failed": "{} nieudanych", - "{} files": "{} plików", + "{} contributors · {} commits": "Współtwórcy: {} · zatwierdzenia: {}", + "{} failed": "{}: niepowodzenie", + "{} files": "Pliki: {}", "{} files and {f} folder{}": "Pliki: {}, foldery: {f}{:.0}", "{} item{}": "Elementy: {}{:.0}", "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", - "{} photos": "{} zdjęć", - "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", - "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", + "{} photos": "Zdjęcia: {}", + "{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}", "{} sample{}": "Próbki: {}{:.0}", "{} sec": "{} s", - "{} spot(s) on this photo": "{} plamek na tym zdjęciu", + "{} spot(s) on this photo": "Plamki na tym zdjęciu: {}", "{} — show their photos": "{} — pokaż ich zdjęcia", "· {dups} already in the library": "· {dups} już w bibliotece", "· {n} photo{}": "· zdjęcia: {n}{:.0}", @@ -184,7 +184,7 @@ "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", "Filtered: {label}": "Filtrowanie: {label}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Podobne zdjęcia: {n}{:.0} · Widok → Wyczyść filtry, aby zobaczyć wszystkie", - "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", + "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązane zdjęcia: {}", "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", ", {} star{}": ", gwiazdki: {}{:.0}", ", {} label": ", {} etykieta", @@ -199,7 +199,7 @@ "{} MB": "{} MB", "Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%", "{name} · {terms}": "{name} · {terms}", - "{n} {noun}": "{n} {noun}", + "{n} {noun}": "{noun}: {n}", "Trained on: {provenance}": "Wytrenowano na: {provenance}", " · {queued} waiting": " · {queued} oczekuje", "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.", @@ -252,50 +252,58 @@ " · kept, not applied: {}": " · zachowane, niezastosowane: {}", " — kept but not applied: {}": " — zachowane, ale niezastosowane: {}", "Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach", - "Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?", - "Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?", - "Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.", + "Delete the effect preset “{name}”?": "Usunąć ustawienie efektu „{name}”?", + "Delete these {n} effect presets?": "Usunąć zaznaczone ustawienia efektów ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia efektów ({n})?{:.0} Tej operacji nie można cofnąć.", "Effect: {name}": "Efekt: {name}", - "Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}", + "Exported {} effect preset{}": "Wyeksportowane ustawienia efektów: {}{:.0}", "Imported {e} mask effect{} (Masking ▸ Effect)": "Zaimportowane efekty masek: {e}{:.0} (Maskowanie → Efekt)", - "Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami", - "{n} preset{}": "Ustawienia wstępne: {n}{:.0}", + "Update “{name}” with Current Settings": "Zaktualizuj „{name}” bieżącymi ustawieniami", + "{n} preset{}": "Ustawienia: {n}{:.0}", "{count} more warnings.": "Pozostałe ostrzeżenia: {count}.", "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.", "{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.", - "Applied {name}": "Zastosowano: {name}", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia Znajdź brakujące zdjęcia, aby je ponownie powiązać.", + "Applied {name}": "Zastosowano {name}", "Delete “{name}”?": "Usunąć „{name}”?", - "Updated {name}": "Zaktualizowano: {name}", + "Updated {name}": "Zaktualizowano {name}", "Exported to {path}": "Wyeksportowano do {path}", "Saved template {name}": "Zapisano szablon {name}", - "Imported {name}": "Zaimportowano: {name}", + "Imported {name}": "Zaimportowano {name}", "Skipped {problem}": "Pominięto: {problem}", "…and {more} more": "…i jeszcze {more}", "Template preview: {name}": "Podgląd szablonu: {name}", "{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}", "{n} pages": "Strony: {n}", "{n} JPEG files": "Pliki JPEG: {n}", - "{s} s of AV1 video": "{s} s wideo AV1", + "{s} s of AV1 video": "Wideo AV1: {s} s", "Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}", " · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}", - "{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.", + "{n} raws can now be developed from their raw data": "Pliki RAW, które można już wywołać z danych surowych: {n}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików XMP dla zdjęć ({n}) — czy ich folder jest niedostępny? LightCraft ponawia próby.", "Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”", - "new, inside {parent}": "nowe, wewnątrz {parent}", + "new, inside {parent}": "nowe, w {parent}", "Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” ze zdjęć ({n}). Edytuj → Cofnij przywraca.", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})", + "Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (twarze: {n}).{:.0} Pliki nie są zmieniane.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij rozdzieli je z powrotem.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano scalenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano scalenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar: {full})", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)", "{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}", "Already in “{}”": "Już w „{}”", "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Przeglądanie folderów — foldery: {folders}, pliki: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je.", + "Drop to review {n} item{} for import": "Upuść, aby przejrzeć przed importem (elementy: {n}){:.0}", + "Drop to import {n} item{}": "Upuść, aby zaimportować (elementy: {n}){:.0}", + "Drop to import {n} preset{}": "Upuść, aby zaimportować ustawienia wstępne: {n}{:.0}", + "Could not read {name}: {e}": "Nie można odczytać {name}: {e}", + "Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}", + "Inside: {names}": "Wewnątrz: {names}", + "Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}" } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index aba88ef44..f03a81762 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -210,7 +210,6 @@ "Clear Filters": "Wyczyść filtry", "Clear Guides": "Wyczyść prowadnice", "Clear History": "Wyczyść historię", - "Clear Preview Cache": "Wyczyść pamięć podręczną podglądów", "Clear Quick Collection": "Wyczyść szybką kolekcję", "Clear Reference": "Wyczyść zdjęcie referencyjne", "Clear all": "Wyczyść wszystko", @@ -260,7 +259,6 @@ "Company": "Firma", "Compare": "Porównaj", "Compare (Shift+C)": "Porównaj (Shift+C)", - "Compare Before and After": "Porównaj przed i po", "Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć", "Component options": "Opcje składnika", "Compression": "Kompresja", @@ -1319,15 +1317,11 @@ "Short Edge": "Krótsza krawędź", "Show": "Pokaż", "Show All": "Pokaż wszystko", - "Show Clipping": "Pokaż prześwietlenia i niedoświetlenia", "Show Deghost Overlay": "Pokaż nakładkę usuwania duchów", "Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne", "Show Luminance Map": "Pokaż mapę luminancji", "Show Mask": "Pokaż maskę", - "Show Mask Overlay": "Pokaż nakładkę maski", - "Show Mask Pins": "Pokaż znaczniki masek", "Show Original": "Pokaż oryginał", - "Show Photo Counts": "Pokaż liczbę zdjęć", "Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym", "Show Pins": "Pokaż znaczniki", "Show Source": "Pokaż źródło", @@ -1711,17 +1705,17 @@ "Use": "Użyj", "Waiting to start…": "Oczekiwanie na uruchomienie…", "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.", - "cached picture": "obraz w pamięci podręcznej", - "cached pictures": "obrazy w pamięci podręcznej", + "cached picture": "Obrazy w pamięci podręcznej", + "cached pictures": "Obrazy w pamięci podręcznej", "commercial use allowed": "dozwolony użytek komercyjny", "in use": "w użyciu", "installed": "zainstalowano", "makes AI Denoise available": "włącza odszumianie AI", "non-commercial use only": "wyłącznie do użytku niekomercyjnego", - "photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.", - "photo waiting": "zdjęcie oczekuje", - "photos have their denoised picture.": "zdjęcia mają już odszumione wersje.", - "photos waiting": "zdjęć oczekuje", + "photo has its denoised picture.": "Zdjęcia z odszumioną wersją", + "photo waiting": "Oczekujące zdjęcia", + "photos have their denoised picture.": "Zdjęcia z odszumioną wersją", + "photos waiting": "Oczekujące zdjęcia", "starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI", "terms unclear": "niejasne warunki", "the graphics card": "karta graficzna", @@ -2138,7 +2132,6 @@ "Remove from favorites": "Usuń z ulubionych", "Reorder Album": "Zmień kolejność w albumie", "Reset Sidebar Sections": "Przywróć sekcje paska bocznego", - "Show All Mask Overlays": "Pokaż wszystkie nakładki masek", "Show Person": "Pokaż osobę", "Show Sidebar Section": "Pokaż sekcję paska bocznego", "Show all overlays": "Pokaż wszystkie nakładki", @@ -2236,7 +2229,6 @@ "Background Tasks": "Zadania w tle", "Cancel Background Task": "Anuluj zadanie w tle", "Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie", - "Show Focus Point": "Pokaż punkt ostrości", "Zoom to Focus Point": "Powiększ do punktu ostrości", "Show in Compare": "Pokaż w porównaniu", "Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce", @@ -2269,5 +2261,24 @@ "SDR Shadows": "Cienie SDR", "SDR Whites": "Biele SDR", "SDR Clarity": "Przejrzystość SDR", - "Face Models on the GPU": "Modele twarzy przetwarzane na GPU" + "Face Models on the GPU": "Modele twarzy przetwarzane na GPU", + "Before/After Side by Side": "Przed/Po obok siebie", + "Clipping": "Prześwietlenia i niedoświetlenia", + "Mask Overlay": "Nakładka maski", + "Mask Pins": "Znaczniki masek", + "Photo Counts": "Liczba zdjęć", + "All Mask Overlays": "Wszystkie nakładki masek", + "Focus Point": "Punkt ostrości", + "Layout Image": "Obraz układu", + "Detail View": "Widok szczegółów", + "Edit Panel": "Panel Edytuj", + "Close the open dialog, then drop the files again": "Zamknij otwarte okno dialogowe, a potem upuść pliki ponownie", + "Close the open dialog to drop files here": "Zamknij otwarte okno dialogowe, aby upuścić tu pliki", + "Drop to open the face model": "Upuść, aby otworzyć model twarzy", + "Drop to import": "Upuść, aby zaimportować", + "Dropped Files": "Upuszczone pliki", + "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu", + "An import is running": "Import jest w toku", + "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy", + "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index bab81a368..f17f2c0fd 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.", "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…", + "Inside: {names}": "Dentro: {names}", "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?", "There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?", "Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem", "Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})", + "Drop to review {n} item{} for import": "Solte para revisar antes de importar (itens: {n}){:.0}", + "Drop to import {n} item{}": "Solte para importar (itens: {n}){:.0}", + "Drop to import {n} preset{}": "Solte para importar predefinições: {n}{:.0}", + "Could not read {name}: {e}": "Não foi possível ler {name}: {e}", + "Added {n} photo{}": "{n} foto{0} adicionada{0}", + "Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?", + "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 5b4ae4409..88e955d26 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -221,7 +221,6 @@ "Clear Filters": "Limpar filtros", "Clear Guides": "Limpar guias", "Clear History": "Limpar histórico", - "Clear Preview Cache": "Limpar cache de prévias", "Clear Quick Collection": "Limpar coleção rápida", "Clear Reference": "Limpar referência", "Clear all": "Limpar tudo", @@ -272,7 +271,7 @@ "Company": "Empresa", "Compare": "Comparar", "Compare (Shift+C)": "Comparar (Shift+C)", - "Compare Before and After": "Comparar antes e depois", + "Before/After Side by Side": "Antes/Depois lado a lado", "Compare needs at least two photos": "A comparação requer pelo menos duas fotos", "Component options": "Opções do componente", "Compression": "Compressão", @@ -1366,17 +1365,17 @@ "Short Edge": "Aresta curta", "Show": "Mostrar", "Show All": "Mostrar tudo", - "Show Clipping": "Mostrar recorte de tons", + "Clipping": "Recorte de tons", "Show Deghost Overlay": "Mostrar sobreposição de remoção de fantasma", "Show Hidden Local Locations": "Mostrar locais ocultos de Local", "Show Luminance Map": "Mostrar mapa de luminância", "Show Mask": "Mostrar máscara", - "Show Mask Overlay": "Mostrar sobreposição da máscara", - "Show All Mask Overlays": "Mostrar todas as sobreposições de máscara", + "Mask Overlay": "Sobreposição da máscara", + "All Mask Overlays": "Todas as sobreposições de máscara", "Show all overlays": "Mostrar todas as sobreposições", - "Show Mask Pins": "Mostrar pinos da máscara", + "Mask Pins": "Pinos da máscara", "Show Original": "Mostrar original", - "Show Photo Counts": "Mostrar contagem de fotos", + "Photo Counts": "Contagem de fotos", "Show Photos with Keyword": "Mostrar fotos com a palavra-chave", "Show Pins": "Mostrar pinos", "Show Source": "Mostrar origem", @@ -2154,7 +2153,7 @@ "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto → Excluir foto", "LightCraft stopped": "O LightCraft parou", - "Show Focus Point": "Mostrar ponto de foco", + "Focus Point": "Ponto de foco", "Zoom to Focus Point": "Zoom no ponto de foco", "Focus": "Foco", "Focus point": "Ponto de foco", @@ -2188,5 +2187,17 @@ "Color Overlay on B&W": "Sobreposição de cor em P&B", "Image on Black": "Imagem sobre preto", "Image on White": "Imagem sobre branco", - "White on Black": "Branco sobre preto" + "White on Black": "Branco sobre preto", + "Layout Image": "Imagem de layout", + "Detail View": "Exibição de detalhe", + "Edit Panel": "Painel Editar", + "Close the open dialog, then drop the files again": "Feche a caixa de diálogo aberta e solte os arquivos novamente", + "Close the open dialog to drop files here": "Feche a caixa de diálogo aberta para soltar arquivos aqui", + "Drop to open the face model": "Solte para abrir o modelo de rostos", + "Drop to import": "Solte para importar", + "Dropped Files": "Arquivos soltos", + "Import dropped files without review": "Importar arquivos soltos sem revisão", + "An import is running": "Uma importação está em andamento", + "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar", + "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 1efc30268..76b7af427 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.", "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…", + "Inside: {names}": "Внутри: {names}", "“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?", "There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?", "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.", + "Drop to review {n} item{} for import": "Отпустите для проверки перед импортом (элементов: {n}){:.0}", + "Drop to import {n} item{}": "Отпустите для импорта (элементов: {n}){:.0}", + "Drop to import {n} preset{}": "Отпустите для импорта пресетов: {n}{:.0}", + "Could not read {name}: {e}": "Не удалось прочитать {name}: {e}", + "Added {n} photo{}": "Добавлено фото: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?", + "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf191bb51..5c8816c5e 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -181,7 +181,6 @@ "Clear Filters": "Сбросить фильтры", "Clear Guides": "Убрать направляющие", "Clear History": "Очистить историю", - "Clear Preview Cache": "Очистить кэш просмотров", "Clear Quick Collection": "Очистить быструю коллекцию", "Clear Reference": "Убрать эталон", "Clear all": "Очистить всё", @@ -223,7 +222,7 @@ "Comments panel": "Панель комментариев", "Compare": "Сравнение", "Compare (Shift+C)": "Сравнение (Shift+C)", - "Compare Before and After": "Сравнить «до» и «после»", + "Before/After Side by Side": "До/после рядом", "Compare needs at least two photos": "Для сравнения нужно минимум два фото", "Component options": "Параметры компонента", "Compression": "Сжатие", @@ -1134,17 +1133,17 @@ "Sharpness": "Резкость", "Short Edge": "Короткая сторона", "Show All": "Показать все", - "Show Clipping": "Показать отсечение", + "Clipping": "Отсечение", "Show Deghost Overlay": "Показать наложение удаления артефактов", "Show Hidden Local Locations": "Показать скрытые места в «Локальном»", "Show Luminance Map": "Показать карту яркости", "Show Mask": "Показать маску", - "Show Mask Overlay": "Показать наложение маски", - "Show All Mask Overlays": "Показать все наложения масок", + "Mask Overlay": "Наложение маски", + "All Mask Overlays": "Все наложения масок", "Show all overlays": "Показать все наложения", - "Show Mask Pins": "Показать булавки масок", + "Mask Pins": "Булавки масок", "Show Original": "Показать оригинал", - "Show Photo Counts": "Показывать количество фото", + "Photo Counts": "Количество фото", "Show Photos with Keyword": "Показать фото с ключевым словом", "Show Pins": "Показать булавки", "Show Source": "Показать источник", @@ -2307,7 +2306,7 @@ "Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото → Удалить фото", "LightCraft stopped": "LightCraft остановился", - "Show Focus Point": "Показать точку фокусировки", + "Focus Point": "Точка фокусировки", "Zoom to Focus Point": "Увеличить по точке фокусировки", "Focus": "Фокус", "Focus point": "Точка фокусировки", @@ -2335,5 +2334,17 @@ "Checking the GPU…": "Проверка ГП…", "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.", - "Checking the GPU for faces": "Проверка ГП для лиц" + "Checking the GPU for faces": "Проверка ГП для лиц", + "Layout Image": "Изображение макета", + "Detail View": "Режим детализации", + "Edit Panel": "Панель «Правка»", + "Close the open dialog, then drop the files again": "Закройте открытое окно и перетащите файлы ещё раз", + "Close the open dialog to drop files here": "Закройте открытое окно, чтобы перетащить сюда файлы", + "Drop to open the face model": "Отпустите, чтобы открыть модель лиц", + "Drop to import": "Отпустите для импорта", + "Dropped Files": "Перетащенные файлы", + "Import dropped files without review": "Импортировать перетащенные файлы без проверки", + "An import is running": "Импорт уже выполняется", + "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится", + "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото" } diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index 50e3fa4be..9a71a0288 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -297,5 +297,13 @@ "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.", "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.", - "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}" + "Listing folders — folders: {folders}, files: {files}": "Listing folders — folders: {folders}, files: {files}", + "Added {n} photo{}": "{n} foto tillagda{:.0}", + "Could not read {name}: {e}": "Kunde inte läsa {name}: {e}", + "Drop to import {n} item{}": "Släpp för att importera {n} objekt{:.0}", + "Drop to import {n} preset{}": "Släpp för att importera {n} förinställningar{:.0}", + "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}", + "Inside: {names}": "Inuti: {names}", + "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?", + "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})" } diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index 76f08c2d3..d9af4b8e8 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -350,7 +350,6 @@ "Clear form": "Rensa formulär", "Clear Guides": "Rensa stödlinjer", "Clear History": "Rensa historiken", - "Clear Preview Cache": "Rensa förhandsgranskningscache", "Clear Quick Collection": "Rensa snabbsamling", "Clear rating": "Rensa betyg", "Clear rating and advance": "Rensa betyg och gå vidare", @@ -416,7 +415,6 @@ "Company": "Företag", "Compare": "Jämför", "Compare (Shift+C)": "Jämför (Shift+C)", - "Compare Before and After": "Jämför före och efter", "Compare needs at least two photos": "Jämförelse kräver minst två foton", "Component options": "Komponentalternativ", "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Komprimerade Nikon-NEF (förlustfritt och förstörande komprimerade, 12 och 14 bitar – t.ex. D3200, D5100, D7000, D750, D850, Z 50)", @@ -1975,9 +1973,7 @@ "Show": "Visa", "Show / hide": "Visa/dölj", "Show All": "Visa alla", - "Show All Mask Overlays": "Visa alla masköverlägg", "Show all overlays": "Visa alla överlägg", - "Show Clipping": "Visa klippning", "Show commands without a shortcut": "Visa kommandon utan kortkommando", "Show Deghost Overlay": "Visa överlägg för spökborttagning", "Show destination gamut warning": "Visa färgomfångsvarning för målet", @@ -1996,13 +1992,10 @@ "Show Luminance Map": "Visa luminanskarta", "Show Mask": "Visa mask", "Show mask": "Visa mask", - "Show Mask Overlay": "Visa masköverlägg", - "Show Mask Pins": "Visa masknålar", "Show on Map": "Visa på kartan", "Show Original": "Visa original", "Show overlay (O)": "Visa överlägg (O)", "Show Person": "Visa person", - "Show Photo Counts": "Visa antal foton", "Show photos": "Visa foton", "Show Photos with Keyword": "Visa foton med nyckelordet", "Show Pins": "Visa nålar", @@ -2414,5 +2407,24 @@ "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-dra för att räta upp, ⇧G guidad upprätning, ett rutnät under transformering.", "★ Favorites": "★ Favoriter", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "All Mask Overlays": "Alla masköverlägg", + "Before/After Side by Side": "Före/efter sida vid sida", + "Clipping": "Klippning", + "Detail View": "Detaljvy", + "Edit Panel": "Redigeringspanel", + "Focus Point": "Fokuspunkt", + "Layout Image": "Layoutbild", + "Mask Overlay": "Masköverlägg", + "Mask Pins": "Masknålar", + "Photo Counts": "Antal foton", + "An import is running": "En import pågår", + "An import is running. Drop photos when it has finished": "En import pågår. Släpp fotona när den är klar", + "Close the open dialog to drop files here": "Stäng den öppna dialogrutan för att släppa filer här", + "Close the open dialog, then drop the files again": "Stäng den öppna dialogrutan och släpp sedan filerna igen", + "Drop to import": "Släpp för att importera", + "Drop to open the face model": "Släpp för att öppna ansiktsmodellen", + "Dropped Files": "Släppta filer", + "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton", + "Import dropped files without review": "Importera släppta filer utan granskning" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 0ada44e08..36022ac1a 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -223,6 +223,7 @@ "Delete the keyword “{name}”?": "Видалити ключове слово «{name}»?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Його буде знято з фото (кількість: {count}{:.0}) разом із вкладеними ключовими словами. Скасування поверне його.", "Create Keyword Tag Inside “{name}”…": "Створити ключове слово в «{name}»…", + "Inside: {names}": "Усередині: {names}", "“{parent}” has a “{name}” already. Merge the two?": "«{parent}» уже містить «{name}». Об’єднати їх?", "There is a “{name}” at the top level already. Merge the two?": "На верхньому рівні вже є «{name}». Об’єднати їх?", "Added “{keyword}” to {n} photo{}": "«{keyword}» додано до фото: {n}{:.0}", @@ -297,5 +298,12 @@ "Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?", "Listing folders — folders: {folders}, files: {files}": "Перегляд тек — тек: {folders}, файлів: {files}", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх." + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.", + "Drop to review {n} item{} for import": "Відпустіть для перегляду перед імпортом (елементів: {n}){:.0}", + "Drop to import {n} item{}": "Відпустіть для імпорту (елементів: {n}){:.0}", + "Drop to import {n} preset{}": "Відпустіть для імпорту наборів налаштувань: {n}{:.0}", + "Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}", + "Added {n} photo{}": "Додано фото: {n}{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?", + "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 4dff39f78..39090c0b3 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -533,7 +533,6 @@ "Clean Studio": "Чиста студія", "Clear Cache": "Очистити кеш", "Clear Filters": "Скинути фільтри", - "Clear Preview Cache": "Очистити кеш переглядів", "Clear Quick Collection": "Очистити швидку колекцію", "Clear Reference": "Очистити еталон", "Clear all": "Очистити все", @@ -562,7 +561,7 @@ "Command Journal": "Журнал команд", "Compare": "Порівняти", "Compare (Shift+C)": "Порівняти (Shift+C)", - "Compare Before and After": "Порівняти до й після", + "Before/After Side by Side": "До/після поруч", "Compare needs at least two photos": "Для порівняння потрібно щонайменше два фото", "Component options": "Параметри компонента", "Concrete Cool": "Холодний бетон", @@ -1360,16 +1359,16 @@ "Shadows split": "Розділення тіней", "Shift by": "Зсунути на", "Short Edge": "Короткий край", - "Show Clipping": "Показати відсікання", + "Clipping": "Відсікання", "Show Deghost Overlay": "Показати накладання усунення двоїння", "Show Hidden Local Locations": "Показати приховані локальні розташування", "Show Luminance Map": "Показати карту яскравості", - "Show Mask Overlay": "Показати накладання маски", - "Show All Mask Overlays": "Показати всі накладення масок", + "Mask Overlay": "Накладання маски", + "All Mask Overlays": "Усі накладання масок", "Show all overlays": "Показати всі накладення", - "Show Mask Pins": "Показати маркери масок", + "Mask Pins": "Маркери масок", "Show Original": "Показати оригінал", - "Show Photo Counts": "Показати кількість фото", + "Photo Counts": "Кількість фото", "Show Photos with Keyword": "Показати фото з ключовим словом", "Show Pins": "Показати маркери", "Show Source": "Показати джерело", @@ -2312,7 +2311,7 @@ "Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото → Видалити фото", "LightCraft stopped": "LightCraft зупинився", - "Show Focus Point": "Показати точку фокусування", + "Focus Point": "Точка фокусування", "Zoom to Focus Point": "Збільшити до точки фокусування", "Focus": "Фокус", "Focus point": "Точка фокусування", @@ -2340,5 +2339,17 @@ "Checking the GPU…": "Перевірка ГП…", "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.", - "Checking the GPU for faces": "Перевірка ГП для облич" + "Checking the GPU for faces": "Перевірка ГП для облич", + "Layout Image": "Зображення макета", + "Detail View": "Детальне подання", + "Edit Panel": "Панель «Редагування»", + "Close the open dialog, then drop the files again": "Закрийте відкрите вікно та перетягніть файли ще раз", + "Close the open dialog to drop files here": "Закрийте відкрите вікно, щоб перетягнути сюди файли", + "Drop to open the face model": "Відпустіть, щоб відкрити модель облич", + "Drop to import": "Відпустіть для імпорту", + "Dropped Files": "Перетягнуті файли", + "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду", + "An import is running": "Імпорт уже виконується", + "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться", + "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото" } diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json index 9ff8ed6b9..d5e28bff0 100644 --- a/crates/ui-egui/locales/vi-formats.json +++ b/crates/ui-egui/locales/vi-formats.json @@ -102,6 +102,7 @@ "Imported {} photo{}": "Đã nhập {} ảnh{:.0}", "In line for AI Denoise ({ahead} ahead)…": "Đang xếp hàng Khử nhiễu AI (trước bạn: {ahead})…", "Insert at the cursor (also written {})": "Chèn tại con trỏ (cũng ghi là {})", + "Inside: {names}": "Bên trong: {names}", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Sẽ gỡ khỏi {count} ảnh{:.0}, kèm từ khóa con. Hoàn tác sẽ khôi phục.", "Keywords of {n} selected photos": "Keywords of {n} selected photos", "Label Set: {name}": "Bộ nhãn: {name}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", "• {assumption}": "• {assumption}", "… and {more} more": "… và {more} nữa", - "…and {more} more": "…and {more} more" + "…and {more} more": "…and {more} more", + "Added {n} photo{}": "Đã thêm {n} ảnh{:.0}", + "Could not read {name}: {e}": "Không thể đọc {name}: {e}", + "Drop to import {n} item{}": "Thả để nhập {n} mục{:.0}", + "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}", + "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?", + "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})" } diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index 6908be70e..98adc91b8 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -288,7 +288,6 @@ "Clear Filters": "Xóa bộ lọc", "Clear Guides": "Xóa đường dẫn", "Clear History": "Xóa lịch sử", - "Clear Preview Cache": "Xóa bộ nhớ đệm xem trước", "Clear Quick Collection": "Xóa Bộ sưu tập nhanh", "Clear Recent Libraries": "Xóa thư viện gần đây", "Clear Reference": "Xóa tham chiếu", @@ -342,7 +341,6 @@ "Company": "Công ty", "Compare": "So sánh", "Compare (Shift+C)": "So sánh (Shift+C)", - "Compare Before and After": "So sánh Trước và Sau", "Compare needs at least two photos": "So sánh cần ít nhất hai ảnh", "Component options": "Tùy chọn thành phần", "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Nikon NEF nén (nén không mất và có mất, 12 và 14 bit — ví dụ D3200, D5100, D7000, D750, D850, Z 50)", @@ -1676,17 +1674,12 @@ "Short Edge": "Cạnh ngắn", "Show": "Hiện", "Show All": "Hiện tất cả", - "Show All Mask Overlays": "Hiện mọi lớp phủ mặt nạ", - "Show Clipping": "Hiện vùng cắt tông", "Show Deghost Overlay": "Hiện lớp phủ deghost", "Show Hidden Local Locations": "Hiện vị trí Local đã ẩn", "Show Luminance Map": "Hiện bản đồ độ sáng", "Show Mask": "Hiện mặt nạ", - "Show Mask Overlay": "Hiện lớp phủ mặt nạ", - "Show Mask Pins": "Hiện ghim mặt nạ", "Show Original": "Hiện bản gốc", "Show Person": "Hiện người", - "Show Photo Counts": "Hiện số lượng ảnh", "Show Photos with Keyword": "Hiện ảnh có từ khóa", "Show Pins": "Hiện ghim", "Show Sidebar Section": "Hiện mục thanh bên", @@ -2220,5 +2213,24 @@ "≥ at least": "≥ tối thiểu", "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-kéo để chỉnh thẳng, ⇧G Chỉnh thẳng có hướng dẫn, lưới khi biến đổi.", "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)" + "繁體中文(台灣)": "繁體中文(台灣)", + "All Mask Overlays": "Mọi lớp phủ mặt nạ", + "Before/After Side by Side": "Trước/Sau cạnh nhau", + "Clipping": "Vùng cắt tông", + "Detail View": "Chế độ xem chi tiết", + "Edit Panel": "Bảng Chỉnh sửa", + "Focus Point": "Điểm lấy nét", + "Layout Image": "Ảnh bố cục", + "Mask Overlay": "Lớp phủ mặt nạ", + "Mask Pins": "Ghim mặt nạ", + "Photo Counts": "Số lượng ảnh", + "An import is running": "Đang có một lần nhập chạy", + "An import is running. Drop photos when it has finished": "Đang có một lần nhập chạy. Hãy thả ảnh khi nó hoàn tất", + "Close the open dialog to drop files here": "Hãy đóng hộp thoại đang mở để thả tệp vào đây", + "Close the open dialog, then drop the files again": "Hãy đóng hộp thoại đang mở, rồi thả lại các tệp", + "Drop to import": "Thả để nhập", + "Drop to open the face model": "Thả để mở mô hình khuôn mặt", + "Dropped Files": "Tệp đã thả", + "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh", + "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index a28b07d2c..7406c39b8 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "要删除关键字“{name}”吗?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}", "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…", + "Inside: {names}": "其中:{names}", "“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?", "There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?", "Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存", "Listing folders — folders: {folders}, files: {files}": "正在列出文件夹 — 文件夹:{folders},文件:{files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})", + "Drop to review {n} item{} for import": "松开鼠标以在导入前检查 {n} 个项目{:.0}", + "Drop to import {n} item{}": "松开鼠标以导入 {n} 个项目{:.0}", + "Drop to import {n} preset{}": "松开鼠标以导入 {n} 个预设{:.0}", + "Could not read {name}: {e}": "无法读取 {name}:{e}", + "Added {n} photo{}": "已添加 {n} 张照片{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里?", + "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”({count} 张照片){:.0}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 4a2ebaa7b..73caf6407 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -220,7 +220,6 @@ "Clear Filters": "清除过滤器", "Clear Guides": "清除参考线", "Clear History": "清除历史记录", - "Clear Preview Cache": "清除预览缓存", "Clear Quick Collection": "清空快速集合", "Clear Reference": "清除参考照片", "Clear all": "全部清除", @@ -270,7 +269,6 @@ "Company": "公司", "Compare": "比较", "Compare (Shift+C)": "比较(Shift+C)", - "Compare Before and After": "比较修改前和修改后", "Compare needs at least two photos": "比较至少需要两张照片", "Component options": "组件选项", "Compression": "压缩", @@ -1351,17 +1349,12 @@ "Short Edge": "短边", "Show": "显示", "Show All": "显示全部", - "Show Clipping": "显示溢出警告", "Show Deghost Overlay": "显示去重影叠加", "Show Hidden Local Locations": "显示隐藏的本地位置", "Show Luminance Map": "显示明亮度图", "Show Mask": "显示蒙版", - "Show Mask Overlay": "显示蒙版叠加", - "Show All Mask Overlays": "显示所有蒙版叠加", "Show all overlays": "显示所有叠加", - "Show Mask Pins": "显示蒙版图钉", "Show Original": "显示原图", - "Show Photo Counts": "显示照片数量", "Show Photos with Keyword": "显示包含该关键字的照片", "Show Pins": "显示图钉", "Show Source": "显示来源", @@ -2153,7 +2146,6 @@ "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 → 删除照片”", "LightCraft stopped": "LightCraft 已停止", - "Show Focus Point": "显示对焦点", "Zoom to Focus Point": "缩放至对焦点", "Focus": "对焦", "Focus point": "对焦点", @@ -2186,5 +2178,24 @@ "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。", "This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", "Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。", - "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)" + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)", + "Before/After Side by Side": "修改前/修改后左右排列", + "Clipping": "溢出警告", + "Mask Overlay": "蒙版叠加", + "All Mask Overlays": "所有蒙版叠加", + "Mask Pins": "蒙版图钉", + "Photo Counts": "照片数量", + "Focus Point": "对焦点", + "Layout Image": "版面图像", + "Detail View": "详细视图", + "Edit Panel": "编辑面板", + "Close the open dialog, then drop the files again": "请先关闭打开的对话框,然后重新拖入文件", + "Close the open dialog to drop files here": "请先关闭打开的对话框,才能在此拖入文件", + "Drop to open the face model": "松开鼠标以打开人脸模型", + "Drop to import": "松开鼠标以导入", + "Dropped Files": "拖入的文件", + "Import dropped files without review": "不经检查直接导入拖入的文件", + "An import is running": "正在导入", + "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片", + "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 119272225..d5c4eb279 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -227,6 +227,7 @@ "Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}", "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…", + "Inside: {names}": "其中:{names}", "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?", "There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?", "Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}", @@ -297,5 +298,12 @@ "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", "Listing folders — folders: {folders}, files: {files}": "正在列出資料夾 — 資料夾:{folders},檔案:{files}", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})", + "Drop to review {n} item{} for import": "放開滑鼠以在匯入前檢查 {n} 個項目{:.0}", + "Drop to import {n} item{}": "放開滑鼠以匯入 {n} 個項目{:.0}", + "Drop to import {n} preset{}": "放開滑鼠以匯入 {n} 個預設集{:.0}", + "Could not read {name}: {e}": "無法讀取 {name}:{e}", + "Added {n} photo{}": "已加入 {n} 張照片{:.0}", + "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡?", + "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」({count} 張照片){:.0}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 78d885128..72cc3ab7f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -220,7 +220,6 @@ "Clear Filters": "清除篩選條件", "Clear Guides": "清除參考線", "Clear History": "清除歷史紀錄", - "Clear Preview Cache": "清除預覽快取", "Clear Quick Collection": "清除快速集合", "Clear Reference": "清除參考照片", "Clear all": "全部清除", @@ -270,7 +269,7 @@ "Company": "公司", "Compare": "比較", "Compare (Shift+C)": "比較(Shift+C)", - "Compare Before and After": "比較編輯前與編輯後", + "Before/After Side by Side": "編輯前/後左右檢視", "Compare needs at least two photos": "比較需要至少兩張照片", "Component options": "元件選項", "Compression": "壓縮", @@ -405,7 +404,7 @@ "Desert Heat": "沙漠熱浪", "Desert Warmth": "沙漠暖色", "Desktop": "桌面", - "Detail": "詳細檢視", + "Detail": "細節", "Detail (D)": "詳細檢視(D)", "Details": "詳細資訊", "Diagonal": "對角線", @@ -1351,17 +1350,17 @@ "Short Edge": "短邊", "Show": "顯示", "Show All": "全部顯示", - "Show Clipping": "顯示溢色", + "Clipping": "溢色", "Show Deghost Overlay": "顯示去除鬼影疊加", "Show Hidden Local Locations": "顯示隱藏的本機位置", "Show Luminance Map": "顯示明度圖", "Show Mask": "顯示遮色片", - "Show Mask Overlay": "顯示遮色片疊加", - "Show All Mask Overlays": "顯示所有蒙版疊加", + "Mask Overlay": "遮色片疊加", + "All Mask Overlays": "所有遮色片疊加", "Show all overlays": "顯示所有疊加", - "Show Mask Pins": "顯示遮色片控制點", + "Mask Pins": "遮色片控制點", "Show Original": "顯示原始照片", - "Show Photo Counts": "顯示照片數量", + "Photo Counts": "照片數量", "Show Photos with Keyword": "顯示含此關鍵字的照片", "Show Pins": "顯示控制點", "Show Source": "顯示來源", @@ -2115,7 +2114,7 @@ "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 → 刪除照片」", "LightCraft stopped": "LightCraft 已停止", - "Show Focus Point": "顯示對焦點", + "Focus Point": "對焦點", "Zoom to Focus Point": "縮放至對焦點", "Focus": "對焦", "Focus point": "對焦點", @@ -2143,5 +2142,17 @@ "Checking the GPU…": "正在檢查 GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。", - "Checking the GPU for faces": "正在檢查用於臉孔的 GPU" + "Checking the GPU for faces": "正在檢查用於臉孔的 GPU", + "Layout Image": "版面圖像", + "Detail View": "詳細檢視", + "Edit Panel": "編輯面板", + "Close the open dialog, then drop the files again": "請先關閉開啟的對話框,然後重新拖放檔案", + "Close the open dialog to drop files here": "請先關閉開啟的對話框,才能在此拖放檔案", + "Drop to open the face model": "放開滑鼠以開啟人臉模型", + "Drop to import": "放開滑鼠以匯入", + "Dropped Files": "拖放的檔案", + "Import dropped files without review": "不經檢查直接匯入拖放的檔案", + "An import is running": "正在匯入", + "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片", + "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入" } diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs index f46156ed9..4e23f1c16 100644 --- a/crates/ui-egui/src/button.rs +++ b/crates/ui-egui/src/button.rs @@ -188,6 +188,7 @@ pub struct Button<'a> { kind: Kind, size: Size, enabled: bool, + busy: bool, widget: Option, tooltip: Option<&'a str>, min_width: f32, @@ -204,6 +205,7 @@ impl<'a> Button<'a> { kind: Kind::default(), size: Size::default(), enabled: true, + busy: false, widget: None, tooltip: None, min_width: 0.0, @@ -256,6 +258,14 @@ impl<'a> Button<'a> { self } + /// Working on what it was pressed for: a spinner takes the icon's place (or sits before the + /// text, inside the button's own padding, so the button keeps its width), a click does nothing, + /// and screen readers hear it is busy. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on /// screen, as every registered id is. pub fn widget(mut self, id: impl Into) -> Self { @@ -331,14 +341,30 @@ impl<'a> Button<'a> { }; // a button in a disabled part of the window is disabled too let enabled = self.enabled && ui.is_enabled(); - let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let resp = ui.interact( + rect, + id, + if !enabled { + Sense::hover() + } else if self.busy { + // still reachable by the keyboard, but nothing to press + Sense::focusable_noninteractive() + } else { + Sense::click() + }, + ); let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + if self.busy { + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_busy()); + } if let Some(w) = &self.widget { crate::widgets::register(ui.ctx(), w.clone(), rect); } let state = if !enabled { State::Disabled + } else if self.busy { + State::Idle } else if resp.is_pointer_button_down_on() { State::Pressed } else if resp.hovered() { @@ -362,16 +388,29 @@ impl<'a> Button<'a> { } let galley = self.galley(ui); let icon_s = self.size.icon(); - let content_w = match (&galley, self.icon) { - (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, - (Some(g), None) => g.size().x, - (None, Some(_)) => icon_s, - (None, None) => 0.0, + // busy: the spinner is the icon's slot; with text alone it is a smaller one in the button's + // own padding (a little tighter), so the button never grows + let spinner_only = self.busy && self.icon.is_none() && galley.is_some(); + let (slot, gap) = if spinner_only { ((self.size.padding() * 2.0 - 12.0).clamp(8.0, icon_s), 4.0) } else { (icon_s, self.size.gap()) }; + let has_slot = self.icon.is_some() || spinner_only; + let content_w = match (&galley, has_slot) { + (Some(g), true) => slot + gap + g.size().x, + (Some(g), false) => g.size().x, + (None, true) => slot, + (None, false) => 0.0, }; let mut x = rect.center().x - content_w / 2.0; - if let Some(icon) = self.icon { - paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); - x += icon_s + self.size.gap(); + if has_slot { + let slot_rect = Rect::from_center_size(pos2(x + slot / 2.0, rect.center().y), vec2(slot, slot)); + if self.busy { + if ui.is_rect_visible(rect) { + crate::progress::paint_spinner(p, slot_rect, lk.content, ui.input(|i| i.time)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(33)); + } + } else if let Some(icon) = self.icon { + paint(p, slot_rect, icon, lk.content); + } + x += slot + gap; } if let Some(g) = galley { let at = pos2(x, rect.center().y - g.size().y / 2.0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index efb3ea3ac..7e04f1356 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -296,6 +296,23 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ctx.request_repaint(); ok(Value::Null) } + "ui.dropFiles" => { + // every entry must be a string: a script is not told `ok` for a part of what it sent + let Some(paths) = + p.get("paths").and_then(Value::as_array).and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect::>>()) + else { + return err("ui.dropFiles: paths must be an array of strings"); + }; + if p.get("hover").and_then(Value::as_bool).unwrap_or(false) { + app.drop_hover = paths; + } else { + // the drag ends with the drop, as it does for the OS + app.drop_hover.clear(); + app.drop_pending = paths; + } + ctx.request_repaint(); + ok(Value::Null) + } "ui.text" => { app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ctx.request_repaint(); @@ -392,8 +409,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } | crate::state::Dialog::SaveEffect { .. } + | crate::state::Dialog::CaptureTime { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) + || (r.is_err() && crate::lightroom_destination::keeps_open_on_error(app, &d)) { app.ui.dialog = Some(d); } diff --git a/crates/ui-egui/src/drop_overlay.rs b/crates/ui-egui/src/drop_overlay.rs new file mode 100644 index 000000000..0e4825988 --- /dev/null +++ b/crates/ui-egui/src/drop_overlay.rs @@ -0,0 +1,99 @@ +//! What dropping the files dragged over the window will do: the same sorting the drop applies, +//! shown on a veil over the whole window while the drag lasts. + +use egui::{Align2, Color32, Context, Order, Rect, Stroke, StrokeKind, vec2}; + +use crate::LightcraftApp; +use crate::theme::Tokens; + +/// Dropped paths by what becomes of them. +#[derive(Default, Debug, PartialEq)] +pub struct Sorted { + /// Face models (`.onnx`): the first opens the licence dialog. + pub models: Vec, + /// Preset files: imported as presets. + pub presets: Vec, + /// Everything else: photos and folders, for the import. + pub photos: Vec, +} + +/// Sort `paths` as a drop does. +pub fn classify(paths: Vec) -> Sorted { + let (models, rest): (Vec, Vec) = paths.into_iter().partition(|p| crate::is_model_file(p)); + let (presets, photos) = crate::split_presets(rest); + Sorted { models, presets, photos } +} + +/// The lines the card shows for files hovering over the window (`unknown`: how many have no path +/// to look at, as on some platforms and in browsers), for the current settings. +fn lines(app: &LightcraftApp, paths: Vec, unknown: bool) -> Vec { + if app.ui.dialog.is_some() { + return vec![crate::i18n::tr("Close the open dialog to drop files here").to_string()]; + } + let drop = classify(paths); + let mut out = Vec::new(); + if !drop.models.is_empty() { + out.push(crate::i18n::tr("Drop to open the face model").to_string()); + } + let plural = |n: usize| if n == 1 { "" } else { "s" }; + // no library open yet (desktop: the web build's drops import into its session as before): + // photos wait for the library chosen, presets are refused (the drop handler does the same) + let waiting = crate::panels::library_problem::holds_drops(app) && !cfg!(target_arch = "wasm32"); + if !drop.presets.is_empty() && waiting { + out.push(crate::i18n::tr("Open a library first, then drop the presets again").to_string()); + } else if !drop.presets.is_empty() { + let n = drop.presets.len(); + out.push(crate::i18n::tr_format!("Drop to import {n} preset{}", plural(n), n = n)); + } + if !drop.photos.is_empty() && waiting { + out.push(crate::i18n::tr("The photos are imported once a library is open").to_string()); + } else if !drop.photos.is_empty() && app.lightroom.is_some() && !cfg!(target_arch = "wasm32") { + // the drop is refused while a Lightroom catalog import owns the catalog + out.push(crate::i18n::tr("Finish Lightroom catalog import before adding photos").to_string()); + } else if !drop.photos.is_empty() && crate::import::running(app) && !cfg!(target_arch = "wasm32") { + out.push(crate::i18n::tr("An import is running. Drop photos when it has finished").to_string()); + } else if !drop.photos.is_empty() { + let n = drop.photos.len(); + // the web build has no review to open: its drops import at once + if app.ui.settings.import_dropped_directly || cfg!(target_arch = "wasm32") { + out.push(crate::i18n::tr_format!("Drop to import {n} item{}", plural(n), n = n)); + } else { + out.push(crate::i18n::tr_format!("Drop to review {n} item{} for import", plural(n), n = n)); + } + } + if out.is_empty() || unknown { + out.push(crate::i18n::tr("Drop to import").to_string()); + } + out +} + +/// Veil and card while files are dragged over the window. +pub fn show(app: &LightcraftApp, ctx: &Context) { + let count = ctx.input(|i| i.raw.hovered_files.len()); + if count == 0 { + return; + } + let known: Vec = ctx.input(|i| { + i.raw.hovered_files.iter().filter_map(|f| f.path.as_ref()).map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect() + }); + let lines = lines(app, known.clone(), known.len() < count); + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new(Order::Tooltip, egui::Id::new("drop-overlay"))); + painter.rect_filled(screen, 0.0, Color32::from_black_alpha(150)); + painter.rect_stroke(screen.shrink(10.0), 12.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + let galleys: Vec<_> = lines.into_iter().map(|l| painter.layout(l, t.semibold(18.0), t.text, (screen.width() - 120.0).max(200.0))).collect(); + let (w, h) = galleys.iter().fold((0.0f32, 0.0f32), |(w, h), g| (w.max(g.size().x), h + g.size().y + 8.0)); + let card = Rect::from_center_size(screen.center(), vec2(w + 64.0, h + 40.0)); + painter.rect_filled(card, 10.0, t.chrome); + painter.rect_stroke(card, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + let mut y = card.top() + 24.0; + for g in galleys { + let at = egui::pos2(card.center().x, y); + let height = g.size().y; + painter.galley(Align2::CENTER_TOP.anchor_size(at, g.size()).min, g, t.text); + y += height + 8.0; + } + crate::widgets::register(ctx, "dropOverlay", card); + ctx.request_repaint(); +} diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs new file mode 100644 index 000000000..62132744a --- /dev/null +++ b/crates/ui-egui/src/frame_rate.rs @@ -0,0 +1,312 @@ +//! Help ▸ Debug ▸ Frame Rate (`view.frameRate`; `LIGHTCRAFT_FPS=1` starts with it on): a +//! frame-rate display in the window's top-right corner, to watch how a slider drag or a zoom holds +//! up. It shows where a frame's time goes: the UI +//! thread's CPU time (layout, painting, texture uploads) and the rest of the frame interval, which +//! is waiting — for vsync, or for the GPU when render work is ahead of the window's frame. A +//! tick over the graph marks each frame in which a render arrived, so spikes can be put next to +//! renders. While it shows, the window repaints continuously, so the rate is the one a drag gets. + +use std::collections::VecDeque; + +use crate::LightcraftApp; +use crate::render::Slot; + +/// Frames kept for the graph (about two seconds at 120 Hz). +const KEEP: usize = 240; + +/// A gap between frames longer than this is a pause (window hidden or occluded, the machine +/// asleep), not a frame: the display starts counting again instead of showing it as a spike. +const PAUSE: f64 = 2.0; + +#[derive(Clone, Copy)] +pub(crate) struct Frame { + at: f64, + /// Seconds since the previous frame. + pub(crate) dt: f64, + /// The previous frame's CPU time on the UI thread, seconds. + cpu: f64, + /// Renders that arrived this frame. + renders: u64, + /// Of those, thrown away (superseded by a newer request). + discarded: u64, +} + +/// The display's state, kept in the context's temp data (one per window or headless view) and +/// dropped while the display is off, so turning it back on starts a fresh count. +#[derive(Clone, Default)] +struct Hud { + last: Option, + completed: u64, + discarded: u64, + frames: VecDeque, + /// When the numbers were last logged. + logged: Option, +} + +fn hud_id() -> egui::Id { + egui::Id::new("fps-hud-state") +} + +/// The display is off: drop its state, so the time it was off never shows as one long frame. +pub(crate) fn forget(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::(hud_id())); +} + +/// Record a frame at `now` (`completed`/`discarded`: the renderer's running totals; `cpu`: the +/// previous frame's UI CPU time, seconds). Returns the frames kept for the display, and whether +/// the numbers are due in the log (once a second; A/B runs read them). +fn record(ctx: &egui::Context, now: f64, completed: u64, discarded: u64, cpu: f64) -> (Vec, bool) { + ctx.data_mut(|d| { + let hud = d.get_temp_mut_or_default::(hud_id()); + match hud.last { + Some(prev) if now - prev > PAUSE || now < prev => hud.frames.clear(), + Some(prev) => { + let renders = completed.saturating_sub(hud.completed); + let thrown = discarded.saturating_sub(hud.discarded); + hud.frames.push_back(Frame { at: now, dt: (now - prev).max(1e-6), cpu, renders, discarded: thrown }); + while hud.frames.len() > KEEP { + hud.frames.pop_front(); + } + } + None => {} + } + hud.last = Some(now); + hud.completed = completed; + hud.discarded = discarded; + let log = hud.logged.is_none_or(|t| now - t >= 1.0 || now < t); + if log { + hud.logged = Some(now); + } + (hud.frames.iter().copied().collect(), log) + }) +} + +/// The frames the display holds now (tests). +#[cfg(test)] +pub(crate) fn frames(ctx: &egui::Context) -> Vec { + ctx.data_mut(|d| d.get_temp::(hud_id())).map(|h| h.frames.into_iter().collect()).unwrap_or_default() +} + +fn mb(bytes: u64) -> f64 { + bytes as f64 / (1 << 20) as f64 +} + +/// Count this frame and draw the display over everything. +pub fn show(ctx: &egui::Context, app: &LightcraftApp) { + let now = ctx.input(|i| i.time); + let cpu = app.perf.host_frame_ms.unwrap_or(0.0) / 1e3; + let (frames, log) = record(ctx, now, app.renderer.completed, app.renderer.discarded, cpu); + + let recent: Vec<&Frame> = frames.iter().filter(|f| now - f.at <= 1.0).collect(); + let fps_now = frames.last().map_or(0.0, |f| 1.0 / f.dt); + let worst = recent.iter().copied().max_by(|a, b| a.dt.total_cmp(&b.dt)); + let avg_fps = if recent.is_empty() { 0.0 } else { recent.len() as f64 / recent.iter().map(|f| f.dt).sum::().max(1e-6) }; + let slow = recent.iter().filter(|f| f.dt > 1.0 / 50.0).count(); + let renders_s: u64 = recent.iter().map(|f| f.renders).sum(); + let discarded_s: u64 = recent.iter().map(|f| f.discarded).sum(); + let window = app.renderer.textures.get(&Slot::Region); + let mem = lightcraft_engine::gpu::memory(); + // (`ready` first: `available` would wait for a device still being created) + let device = match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => format!("off ({why})"), + None if lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available() => { + lightcraft_engine::gpu::adapter_name().unwrap_or_else(|| "on".into()) + } + None => "starting…".into(), + }; + if log { + let (wdt, wcpu) = worst.map_or((0.0, 0.0), |w| (w.dt * 1e3, w.cpu * 1e3)); + let p95 = { + let mut d: Vec = recent.iter().map(|f| f.dt * 1e3).collect(); + d.sort_by(f64::total_cmp); + d.get(d.len().saturating_sub(1) * 95 / 100).copied().unwrap_or(0.0) + }; + log::info!( + "fps-hud: frames {} slow>20ms {slow} p95 {p95:.1} worst {wdt:.1} (cpu {wcpu:.1}) renders {renders_s} discarded {discarded_s} window {:.1} gpu {device}", + recent.len(), + window.map_or(0.0, |t| t.ms) + ); + } + let lines = [ + format!("{fps_now:>4.0} fps avg {avg_fps:.0} {slow} frames > 20 ms in 1 s"), + match worst { + Some(w) => format!("worst {:.1} ms = CPU {:.1} + wait {:.1}", w.dt * 1e3, w.cpu * 1e3, (w.dt - w.cpu).max(0.0) * 1e3), + None => "worst –".to_string(), + }, + format!("UI CPU {:.1} ms (update {:.1})", app.perf.host_frame_ms.unwrap_or(0.0), app.perf.update_ms), + format!("render frame {:.1} ms", app.renderer.last_main_ms), + match window { + Some(t) => format!("render window {:.1} ms", t.ms), + None => "render window –".to_string(), + }, + format!("renders: {} queued, {} running, {renders_s}/s ({discarded_s} thrown away)", app.renderer.queued(), app.renderer.in_flight()), + format!("GPU: {device}"), + format!("GPU mem {:.0} MB (+{:.0} pooled)", mb(mem.allocated), mb(mem.pooled)), + ]; + + let screen = ctx.content_rect(); + let width = 300.0; + egui::Area::new(egui::Id::new("fps-hud")) + .order(egui::Order::Tooltip) + .fixed_pos(egui::pos2(screen.right() - width - 24.0, screen.top() + 56.0)) + .interactable(false) + .show(ctx, |ui| { + egui::Frame::new().fill(egui::Color32::from_black_alpha(210)).corner_radius(6.0).inner_margin(8.0).show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 1.0; + for l in &lines { + ui.label(egui::RichText::new(l).monospace().size(11.0).color(egui::Color32::from_gray(230))); + } + ui.add_space(4.0); + graph(ui, &frames, width); + ui.label( + egui::RichText::new("bars: blue = UI CPU, orange/red = waiting · ticks = a render arrived · line = 60 fps") + .size(9.0) + .color(egui::Color32::from_gray(160)), + ); + }); + }); + // a rate the drag gets, not the idle one + ctx.request_repaint(); +} + +/// Frame intervals, 0 at the bottom and 50 ms at the top: the UI thread's CPU part in blue and +/// the waiting part above it (green within 60 fps, orange within 30, red beyond), with a tick +/// over each frame in which a render arrived. +fn graph(ui: &mut egui::Ui, frames: &[Frame], width: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 70.0), egui::Sense::hover()); + let p = ui.painter_at(rect); + let plot = egui::Rect::from_min_max(egui::pos2(rect.left(), rect.top() + 6.0), rect.max); + let y = |ms: f64| plot.bottom() - (ms / 50.0).clamp(0.0, 1.0) as f32 * plot.height(); + let w = rect.width() / KEEP as f32; + let start = KEEP.saturating_sub(frames.len()); + for (i, f) in frames.iter().enumerate() { + let (ms, cpu) = (f.dt * 1e3, (f.cpu * 1e3).min(f.dt * 1e3)); + let x = rect.left() + (start + i) as f32 * w; + let bar = |from: f64, to: f64, c: egui::Color32| { + p.rect_filled(egui::Rect::from_min_max(egui::pos2(x, y(to)), egui::pos2(x + w.max(1.0), y(from))), 0.0, c); + }; + let wait = if ms > 33.4 { + egui::Color32::from_rgb(230, 80, 70) + } else if ms > 17.5 { + egui::Color32::from_rgb(235, 160, 60) + } else { + egui::Color32::from_rgb(110, 190, 110) + }; + bar(cpu, ms, wait); + bar(0.0, cpu, egui::Color32::from_rgb(90, 150, 240)); + if f.renders > 0 { + p.rect_filled( + egui::Rect::from_min_max(egui::pos2(x, rect.top()), egui::pos2(x + w.max(1.0), rect.top() + 4.0)), + 0.0, + egui::Color32::WHITE, + ); + } + } + p.hline(rect.x_range(), y(1000.0 / 60.0), egui::Stroke::new(1.0, egui::Color32::from_rgb(90, 200, 90))); + p.hline(rect.x_range(), y(1000.0 / 30.0), egui::Stroke::new(1.0, egui::Color32::from_rgb(200, 90, 80).gamma_multiply(0.6))); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serde_json::json; + + use crate::headless::Headless; + use crate::menubar::{MenuNode, menu_bar}; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + fn set(h: &mut Headless, params: serde_json::Value) -> Option { + let r = h.request("engine.execute", json!({"command": "view.frameRate", "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + r["result"]["show"].as_bool() + } + + /// The Help ▸ Debug item for it, and its checkmark. + fn debug_item(app: &LightcraftApp) -> Option> { + let help = menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); + help.into_iter().find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Debug" => children.into_iter().find_map(|c| match c { + MenuNode::Item { id, checked, .. } if id == "view.frameRate" => Some(checked), + _ => None, + }), + _ => None, + }) + } + + /// Help ▸ Debug ▸ Frame Rate toggles the display (a checked menu item), `{show}` sets it, + /// and frames draw with it on. + #[test] + fn show_frame_rate_is_a_debug_toggle() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let was = h.app.ui.frame_rate; + assert_eq!(debug_item(&h.app), Some(Some(was)), "Help ▸ Debug lists it, checked as it is"); + assert_eq!(set(&mut h, json!({})), Some(!was)); + assert_eq!(debug_item(&h.app), Some(Some(!was))); + // frames with the display on draw (and keep the window repainting) + assert_eq!(set(&mut h, json!({"show": true})), Some(true)); + for _ in 0..3 { + h.step(); + } + assert_eq!(set(&mut h, json!({"show": false})), Some(false)); + assert!(!h.app.ui.frame_rate); + } + + fn worst_ms(h: &Headless) -> f64 { + super::frames(&h.view.ctx).iter().map(|f| f.dt * 1e3).fold(0.0, f64::max) + } + + /// Turned off and on again later, the display starts a fresh count: the time it was off is not + /// one long frame (a red spike and a "worst" of the whole time off). + #[test] + fn turning_it_back_on_shows_no_spike_for_the_time_it_was_off() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + set(&mut h, json!({"show": true})); + for _ in 0..3 { + h.step(); + } + assert!(!super::frames(&h.view.ctx).is_empty(), "frames are counted while it shows"); + set(&mut h, json!({"show": false})); + h.step(); + h.time += 1.5; + set(&mut h, json!({"show": true})); + for _ in 0..3 { + h.step(); + } + assert!(worst_ms(&h) < 1000.0, "worst frame {} ms after turning it back on", worst_ms(&h)); + } + + /// A pause while it shows (window hidden, machine asleep) starts a fresh count too. + #[test] + fn a_long_pause_while_it_shows_is_not_a_frame() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + set(&mut h, json!({"show": true})); + for _ in 0..3 { + h.step(); + } + h.time += 30.0; + for _ in 0..3 { + h.step(); + } + assert!(worst_ms(&h) < 1000.0, "worst frame {} ms after a pause", worst_ms(&h)); + } + + /// Each window (egui context) keeps its own count. + #[test] + fn each_context_keeps_its_own_count() { + let (a, b) = (egui::Context::default(), egui::Context::default()); + for t in [0.0, 0.016, 0.033] { + super::record(&a, t, 0, 0, 0.0); + } + assert_eq!(super::frames(&a).len(), 2); + assert!(super::frames(&b).is_empty()); + let (frames, _) = super::record(&b, 100.0, 0, 0, 0.0); + assert!(frames.is_empty(), "a new context's first frame has no interval: {}", frames.len()); + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c33ba0998..a5d05edac 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -234,14 +234,15 @@ pub struct Headless { /// Logical size (points) and scale. pub size: egui::Vec2, pub pixels_per_point: f32, - time: f64, + pub(crate) time: f64, frames: u64, pub(crate) events: Vec, control: Sender, quit: bool, /// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host). pub window_maximized: bool, - /// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first. + /// Window-management and pointer commands the app sent (`StartDrag`, `Maximized`, + /// `CursorPosition`, …), oldest first. pub window_commands: Vec, /// The pointer cursor the last frame asked for (what a real host would show). pub last_cursor: egui::CursorIcon, @@ -342,7 +343,8 @@ impl Headless { self.window_maximized = on; self.window_commands.push(c); } - ViewportCommand::StartDrag => self.window_commands.push(c), + // recorded, not carried out: the simulated pointer moves only when a test moves it + ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => self.window_commands.push(c), _ => {} } } @@ -3362,7 +3364,7 @@ mod tests { } /// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it - /// narrower, though the tabs are of different heights and widths. + /// narrower or taller, though the tabs hold different amounts: the window is one size on every tab. #[test] fn settings_tabs_keep_the_window_in_place() { let mut h = demo([1300.0, 820.0]); @@ -3381,7 +3383,7 @@ mod tests { assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); heights.push(now.height()); } - assert!(heights.iter().any(|h| (h - first.height()).abs() > 20.0), "the tabs differ in height: {heights:?}"); + assert!(heights.iter().all(|h| (h - first.height()).abs() < 1.0), "the tabs keep the height of {}: {heights:?}", first.height()); } /// Settings → Import → "Show the import review when a camera or card is connected": a card that diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3614637a0..c9ee3878c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -266,8 +266,48 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { // `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); -/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays -/// exactly as its owner wrote it. +/// The web build's notice for a dropped file the browser could not read. +pub fn dropped_file_unreadable(name: &str, error: &str) -> String { + tr_format!("Could not read {name}: {e}", name = name, e = error) +} + +/// The web build's notice for dropped files the library refused. +pub fn dropped_import_failed(error: &str) -> String { + tr_format!("Import failed: {e}", e = error) +} + +/// The web build's notice for `n` dropped photos that joined the library. +pub fn dropped_photos_added(n: usize) -> String { + tr_format!("Added {n} photo{}", if n == 1 { "" } else { "s" }, n = n) +} + +/// The web build's notice for `n` dropped photos that came back from Recently Deleted. +pub fn dropped_photos_restored(n: usize) -> String { + tr_format!("Restored {} photo{} from Recently Deleted", n, if n == 1 { "" } else { "s" }) +} + +/// The web build's notice for what a drop of photos came to, from the `library.import` report: every outcome that +/// happened (added, restored from Recently Deleted, not readable), joined with " · " as the desktop import's notice +/// joins its parts, or `None` when none did (duplicates only). +pub fn dropped_photos_outcome(report: &serde_json::Value) -> Option { + let count = |k: &str| report[k].as_array().map_or(0, Vec::len); + let parts: Vec = [ + (count("imported"), dropped_photos_added as fn(usize) -> String), + (count("restored"), dropped_photos_restored), + (count("failed"), dropped_photos_unreadable), + ] + .into_iter() + .filter(|(n, _)| *n > 0) + .map(|(n, say)| say(n)) + .collect(); + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +/// The web build's notice for `n` dropped photos that could not be read. +pub fn dropped_photos_unreadable(n: usize) -> String { + tr_format!("{} photo{} not readable", n, if n == 1 { "" } else { "s" }) +} + /// The title and text of the message box shown when the desktop app's window can't start /// (issue #260), in the UI language: the error and, when there is one, the log file to look in. pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) { @@ -305,6 +345,8 @@ pub fn upper_first(text: &str) -> String { } } +/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays +/// exactly as its owner wrote it. pub fn builtin_label(source: &str, builtin: bool) -> &str { if builtin { tr(source) } else { source } } @@ -1624,6 +1666,95 @@ mod ", let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); } + + /// A drop's notice comes from what `library.import` reported: a photo added, one brought back from Recently + /// Deleted and a file that could not be read are each named; a drop of duplicates only says nothing. + #[test] + fn a_dropped_photos_notice_names_every_outcome_of_the_import_and_is_silent_for_duplicates_only() { + set_language(Locale::En); + let dir = std::env::temp_dir().join(format!("lc-drop-notice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = |name: &str, seed: u8| { + let data: Vec<[u8; 4]> = (0..48 * 32).map(|i| [(i % 48 * 5) as u8, (i / 48 * 7) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 48, height: 32, data }; + let bytes = + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(name), bytes).unwrap(); + dir.join(name).to_string_lossy().into_owned() + }; + let (trashed, added) = (png("trashed.png", 1), png("new.png", 2)); + let broken = dir.join("broken.jpg"); + std::fs::write(&broken, b"not a jpeg at all").unwrap(); + let mut s = lightcraft_engine::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [trashed]})).unwrap(); + s.execute("photo.delete", &serde_json::json!({"ids": [r["imported"][0]]})).unwrap(); + let report = s.execute("library.import", &serde_json::json!({"paths": [trashed, added, broken]})).unwrap(); + assert_eq!( + dropped_photos_outcome(&report).as_deref(), + Some("Added 1 photo · Restored 1 photo from Recently Deleted · 1 photo not readable"), + "{report}" + ); + let again = s.execute("library.import", &serde_json::json!({"paths": [added]})).unwrap(); + assert_eq!(dropped_photos_outcome(&again), None, "a duplicate only: {again}"); + let _ = std::fs::remove_dir_all(dir); + } + + /// Polish: the menu and control channel select it, dates read day.month.year, and every count + /// reads right for any number. Polish has three plural forms (1 zdjęcie, 2 zdjęcia, 5 zdjęć), so + /// counts follow their noun ("Zaimportowane zdjęcia: 22") instead of a form that fits only some. + #[test] + fn polish_switches_formats_dates_and_reads_every_count() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.polish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Pl); + assert_eq!(Locale::Pl.name(), "Polski"); + assert_eq!(crate::menubar::checked(&app, "app.language.polish"), Some(true)); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr("Exposure"), "Ekspozycja"); + assert_eq!(date_group_label("2026-09-20", false), "Niedziela, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Niedziela, 20"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Niedziela, 20.09.2026 16:04:05"); + for n in [0, 1, 2, 3, 4, 5, 12, 21, 22, 25, 111] { + let s = if n == 1 { "" } else { "s" }; + assert_eq!(tr_format!("Imported {} photo{}", n, s), format!("Zaimportowane zdjęcia: {n}")); + for (one, many, noun) in [ + ("photo waiting", "photos waiting", "Oczekujące zdjęcia"), + ("cached picture", "cached pictures", "Obrazy w pamięci podręcznej"), + ("photo has its denoised picture.", "photos have their denoised picture.", "Zdjęcia z odszumioną wersją"), + ] { + assert_eq!(tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })), format!("{noun}: {n}")); + } + assert_eq!(tr_format!("Delete {n} Photos", n = n), format!("Usuń zdjęcia ({n})")); + assert_eq!(tr_format!("{n} preset{}", s, n = n), format!("Ustawienia: {n}")); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Dodano do „{name}”: 22")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Pl); + let ctx = egui::Context::default(); + for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Pl), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Pl); + } + let text = painted_text(&ctx, &mut app, Locale::Pl); + assert!(text.contains("Plik") && text.contains("Ekspozycja"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ„”".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Polish glyph {ch}"); + } + } + }); + set_language(Locale::En); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 323d8d902..d62311c56 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -91,6 +91,10 @@ pub struct ImportDialog { /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). #[serde(skip)] pub last_clicked: Option, + /// The library the scan was made in ([`lightcraft_engine::Session::library_generation`]): its duplicate + /// decisions and album ids belong to that catalog, so the review is not shown or confirmed in another. + #[serde(skip)] + pub library: Option, } /// A candidate's file type, as the review groups them: its format, or its extension when the @@ -114,6 +118,11 @@ impl ImportDialog { let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } } + + /// Whether the library the scan was made in is still the open one (a review that names none counts). + fn in_library(&self, app: &LightcraftApp) -> bool { + self.library.is_none_or(|g| g == app.session.library_generation()) + } /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by /// mistake comes back by importing its file again (issue #706). @@ -471,6 +480,9 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> /// Collect a finished scan and open the review (called every frame). pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { + show_pending_review(app, ctx); let Some(task) = app.scan.as_ref() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100)); let out = match task.rx.try_recv() { @@ -626,7 +639,52 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { // only an album the review offers (it may have been deleted while the scan ran) d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); d.sources = task.sources; - app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + d.library = Some(app.session.library_generation()); + app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + show_pending_review(app, ctx); +} + +/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… +/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was +/// closed meanwhile (Settings can open another) is dropped. +fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.review_pending.is_none() { + return; + } + if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending + && !opts.in_library(app) + { + app.review_pending = None; + return; + } + if app.ui.dialog.is_none() { + app.ui.dialog = app.review_pending.take(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. +#[cfg(test)] +pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { + stall_scan_of(app, false) +} + +/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. +#[cfg(test)] +pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel::(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); + Box::new(tx) +} + +/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's +/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan +/// runs, and a browse that finishes replaces `app.import`. +pub fn running(app: &LightcraftApp) -> bool { + app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() } impl ScanTask { @@ -636,9 +694,17 @@ impl ScanTask { } } -/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). +/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another +/// import runs: replacing `app.import` would stop its worker. pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { busy_synchronizing(app)?; + if !d.in_library(app) { + return Err("another library was opened since the photos were scanned: nothing was imported".into()); + } + // a browse scan that finishes starts its own import, replacing this one + if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { + return Err(crate::i18n::tr("An import is running").to_string()); + } let queue = d.selected_paths(); if queue.is_empty() { return Err("no photos selected".into()); @@ -691,8 +757,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result /// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { busy_synchronizing(app)?; - if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) { - return Err("an import is running".into()); + if running(app) { + return Err(crate::i18n::tr("An import is running").to_string()); } let undo0 = app.session.undo.len(); let total = paths.len(); @@ -1336,7 +1402,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial crate::widgets::announce(&resp, c.name.clone()); register(ui.ctx(), format!("import:{i}"), img); let p = ui.painter(); - p.rect_filled(img, 3.0, Color32::from_gray(30)); + p.rect_filled(img, 3.0, t.skeleton); // thumbnail (background job; the grid only asks for the cells in view) let slot = Slot::Import(i as u32); if !app.renderer.textures.contains_key(&slot) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f23c9bd7..216d6cb61 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,12 +15,15 @@ pub mod control; pub mod credits; pub mod date_picker; pub mod docking; +mod drop_overlay; pub mod export_task; mod focus_scan; +mod frame_rate; pub mod headless; pub mod i18n; pub mod icons; pub mod import; +pub mod lightroom_destination; pub mod lightroom_import; pub mod links; pub mod menu_level; @@ -31,6 +34,8 @@ mod model_setup; pub mod panels; pub mod pick; pub mod print_view; +pub mod progress; +pub mod rating; pub mod recheck; pub mod region; pub mod reload; @@ -65,6 +70,10 @@ mod tests_button; #[cfg(test)] mod tests_canvas_keys; #[cfg(test)] +mod tests_canvas_pan; +#[cfg(test)] +mod tests_capture_time; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; @@ -81,10 +90,14 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialog_primary; #[cfg(test)] +mod tests_dialog_settings; +#[cfg(test)] mod tests_dialog_tab; #[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_drop_import; +#[cfg(test)] mod tests_edit_layout; #[cfg(test)] mod tests_empty_library; @@ -119,10 +132,14 @@ mod tests_keywording; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_launch_library; +#[cfg(test)] mod tests_layout_image; #[cfg(test)] mod tests_library_problem; #[cfg(test)] +mod tests_lightroom_destination; +#[cfg(test)] mod tests_lights_out; #[cfg(test)] mod tests_mask_effects; @@ -133,12 +150,16 @@ mod tests_masking_layout; #[cfg(test)] mod tests_menubar; #[cfg(test)] +mod tests_merge_preview; +#[cfg(test)] mod tests_offline; #[cfg(test)] mod tests_open_lightroom; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_preset_favorites; +#[cfg(test)] mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; @@ -149,8 +170,12 @@ mod tests_profile_menu; #[cfg(test)] mod tests_profiles_layout; #[cfg(test)] +mod tests_progress; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_rating; +#[cfg(test)] mod tests_recheck; #[cfg(test)] mod tests_rename_person; @@ -383,6 +408,11 @@ pub struct LightcraftApp { shadow: Option, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec, + /// Files the control channel (`ui.dropFiles`) holds over the window: every frame reports them + /// as hovered, as the OS does while a drag is over the window. + drop_hover: Vec, + /// Files the control channel dropped: delivered as `dropped_files` on the next frame. + drop_pending: Vec, /// The text the UI last put on the clipboard (`ui.inspect` → `copied`). pub copied: Option, /// Native file dialogs up for commands (`pick`): each command runs again when its closes. @@ -452,6 +482,8 @@ pub struct LightcraftApp { pub lightroom_last: Option, /// A completed import result waiting for another modal dialog to close. pub(crate) lightroom_result: Option, + /// The import review of a finished scan, waiting for another dialog to close. + pub(crate) review_pending: Option, /// The library generation the window title and the recent list were last updated for. pub shown_library: Option, /// A background export in progress. @@ -485,6 +517,12 @@ pub struct LightcraftApp { /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, + /// Alt/Option held as the window first appears offers the recent libraries once (issue + /// #1290); the desktop host arms it when nothing chose the library already. + pub alt_asks_for_library: bool, + /// Whether each recent library can be opened, answered by a worker (issue #1290): the launch + /// list and File ▸ Open Recent Library never look at a folder on the UI thread. + pub recent_checks: panels::library_problem::RecentChecks, /// The activity stack shows every task, not just the first few ("+N more" was clicked). pub activity_expanded: bool, } @@ -514,6 +552,8 @@ impl LightcraftApp { screenshot_token: 0, shadow: None, synthetic: vec![], + drop_hover: vec![], + drop_pending: vec![], copied: None, pending_picks: vec![], synthetic_mods: egui::Modifiers::NONE, @@ -549,6 +589,7 @@ impl LightcraftApp { lightroom: None, lightroom_last: None, lightroom_result: None, + review_pending: None, shown_library: None, export: None, tasks: Default::default(), @@ -564,6 +605,8 @@ impl LightcraftApp { screen_profile: None, screen_profile_read: None, library_problem: None, + alt_asks_for_library: false, + recent_checks: Default::default(), activity_expanded: false, model_setup: Default::default(), } @@ -661,7 +704,7 @@ impl LightcraftApp { // be invisible once the activity stack is gone. Never show success on error. let completed = match id { "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), - "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Clear Preview Cache"))), + "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))), "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), _ => None, }; @@ -1095,7 +1138,7 @@ impl LightcraftApp { pub fn logic(&mut self, ctx: &egui::Context) { i18n::set_language(self.ui.language); let t0 = now_ms(); - panels::library_problem::logic(self); + panels::library_problem::logic(self, ctx); self.logic_inner(ctx); self.perf.logic_ms = now_ms() - t0; } @@ -1251,29 +1294,58 @@ impl LightcraftApp { shortcuts::handle(self, ctx); } #[cfg(not(target_arch = "wasm32"))] + self.handle_dropped_files(ctx); + } + + /// Files dropped on the window: face models open their licence dialog, presets import as + /// presets, photos and folders go to the import review (or straight in, Settings ▸ Import). + #[cfg(not(target_arch = "wasm32"))] + fn handle_dropped_files(&mut self, ctx: &egui::Context) { + let dropped: Vec = + ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect()); + if dropped.is_empty() { + return; + } + // a dialog is on screen: the review would replace it, losing what it holds + if self.ui.dialog.is_some() { + self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again")); + return; + } + let drop = drop_overlay::classify(dropped); + if let Some(model) = drop.models.first() + && let Err(e) = panels::faces::open_dialog(self, model) { - let dropped: Vec = - ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect()); - // preset files import as presets, everything else as photos - // a dropped face model opens its licence dialog; presets and photos as before - let (models, dropped): (Vec, Vec) = dropped.into_iter().partition(|p| is_model_file(p)); - if let Some(model) = models.first() - && let Err(e) = panels::faces::open_dialog(self, model) - { - self.toast(ctx, e); - } - let (presets, photos) = split_presets(dropped); - if !presets.is_empty() { - let _ = self.run("file.importPresets", serde_json::json!({"paths": presets})); + self.toast(ctx, e); + } + if !drop.presets.is_empty() { + // presets live in a library: with none open they would go with the session it replaces + if panels::library_problem::holds_drops(self) { + self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again")); + } else { + let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets})); } + } + if drop.photos.is_empty() { + return; + } + // no library open yet (the launch list, a library that failed to open): they wait for one + let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return }; + if self.lightroom.is_some() { + self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos")); + return; + } + if import::running(self) { + self.toast(ctx, crate::i18n::tr("An import is running")); + return; + } + let started = if self.ui.settings.import_dropped_directly { // read and added on a worker thread (dropped folders can be large, or on a slow drive) - if !photos.is_empty() { - if self.lightroom.is_some() { - self.toast(ctx, "Finish Lightroom catalog import before adding photos"); - } else if let Err(e) = import::start_paths(self, photos) { - self.toast(ctx, e); - } - } + import::start_paths(self, photos).map(|_| ()) + } else { + import::open(self, photos).map(|_| ()) + }; + if let Err(e) = started { + self.toast(ctx, e); } } @@ -1366,6 +1438,16 @@ impl LightcraftApp { self.synthetic_mods = egui::Modifiers::NONE; raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); } + let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p)); + if !self.drop_pending.is_empty() { + raw.dropped_files = std::mem::take(&mut self.drop_pending) + .into_iter() + .map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle) + .collect(); + } + if !self.drop_hover.is_empty() { + raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect(); + } if self.synthetic.is_empty() { return; } @@ -1420,6 +1502,12 @@ impl LightcraftApp { /// Frame timings once layout is done (`t0`: when layout started). fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { + if self.ui.frame_rate { + frame_rate::show(ctx, self); + } else { + frame_rate::forget(ctx); + } + panels::edit::wheel_pointer_frame_end(ctx); ctx.output(|o| { for c in &o.commands { if let egui::OutputCommand::CopyText(text) = c { @@ -1443,6 +1531,12 @@ impl LightcraftApp { if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { let path = l.dir.to_string_lossy().into_owned(); panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + // it is open: whatever an earlier check said about it is over + self.recent_checks.opened(&path); + // when, for the launch window (issue #1290); only the listed libraries keep a time + let settings = &mut self.ui.settings; + settings.recent_opened.insert(path, (self.session.clock)()); + settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); } ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); } @@ -1455,6 +1549,18 @@ impl LightcraftApp { } } + /// The browser page's title: "[ •] — LightCraft" for the active photo (• while + /// `unsaved`: changes not yet written to storage), else the window title. The web build has no + /// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which + /// document a drop opened and whether it is saved (issue #2397). + pub fn page_title(&self, unsaved: bool) -> String { + let dot = if unsaved { " •" } else { "" }; + match self.session.active().and_then(|id| self.session.catalog.photo(id)) { + Some(p) => format!("{}{dot} — LightCraft", p.file_name), + None => format!("{}{dot}", self.window_title()), + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { i18n::set_language(self.ui.language); @@ -1483,6 +1589,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.end_frame(&ctx, t0); return; @@ -1510,6 +1617,7 @@ impl LightcraftApp { panels::left::section_drag_feedback(self, &ctx); panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); + drop_overlay::show(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.text_focus = ctx.egui_wants_keyboard_input(); self.end_frame(&ctx, t0); @@ -1581,6 +1689,26 @@ fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> } } +/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`. +#[derive(Debug)] +struct InjectedDrop(std::path::PathBuf); + +impl egui::DroppedFile for InjectedDrop { + fn path(&self) -> &std::path::Path { + &self.0 + } + + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Err("the web build has no files to read".to_string()) }) + } +} + /// A dropped face model file (`.onnx`). pub fn is_model_file(path: &str) -> bool { std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) diff --git a/crates/ui-egui/src/lightroom_destination.rs b/crates/ui-egui/src/lightroom_destination.rs new file mode 100644 index 000000000..a62c59825 --- /dev/null +++ b/crates/ui-egui/src/lightroom_destination.rs @@ -0,0 +1,366 @@ +//! File ▸ Import Lightroom Catalog…: where the catalog goes (issue #2672). +//! +//! Before the import starts, a window asks whether the catalog's photos, collections and edits +//! merge into the library open now or go into a new library, named after the catalog and put next +//! to the open one. The new library is made and opened as File ▸ New Library… does +//! (`app.newLibrary`: refused in a folder that isn't empty, or while another import runs), then the +//! catalog is imported into it. Settings ▸ General ▸ Library (or the window's "Don't ask again") +//! turns the question off: the catalog then merges into the open library, as it always did. +//! +//! Only the app command `file.importLightroom` changes: it takes `{into?: "open" | "new", name?, +//! location?}` so the control channel can make the choice without the window. The import itself +//! (`library.importLightroom`, [`crate::lightroom_import`]) is unchanged. A session with no +//! library on disk (in memory) has nothing to put a new library next to: it merges without asking. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::{Dialog, LightroomImport}; + +/// Where `file.importLightroom {into}` puts the catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Destination { + /// `"open"`: merge into the library open now. + Open, + /// `"new"`: a new library, opened before the import. + New, +} + +/// The destination params of `file.importLightroom`, checked. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Request { + /// `into`; `None`: ask (or merge, as the setting says). + pub into: Option, + /// `name`: the new library's name (default: the catalog file's name without `.lrcat`). + pub name: Option, + /// `location`: the folder the new library goes in (default: the open library's parent folder). + pub location: Option, +} + +impl Request { + /// Read `{into?, name?, location?}` from `file.importLightroom`'s params. A wrong type or an + /// unknown `into` is an error, before any file dialog opens. + pub fn parse(p: &Value) -> Result { + let into = match p.get("into") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s == "open" => Some(Destination::Open), + Some(Value::String(s)) if s == "new" => Some(Destination::New), + Some(v) => return Err(format!("file.importLightroom: `into` must be \"open\" or \"new\", not {v}")), + }; + let text = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(v) => Err(format!("file.importLightroom: `{key}` must be a string, not {v}")), + }; + Ok(Request { into, name: text("name")?, location: text("location")? }) + } +} + +/// What `file.importLightroom` does with a catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Plan { + /// Import into the library open now. + Merge, + /// Make a new library, open it, import into it. + NewLibrary, + /// Show the window; `new_library`: "Create a new library" is the choice it opens with. + Ask { new_library: bool }, +} + +/// The decision, from the setting, the open library (`on_disk`: saved in a folder, not in memory; +/// `photos`: how many it holds) and the command's `into`. `into` always wins; without it a library +/// in memory, or the setting "Always merge", merges; otherwise the window asks, opening on "Merge" +/// when the library is empty (there is nothing to merge with) and on "Create a new library" when +/// it holds photos. +pub fn decide(setting: LightroomImport, on_disk: bool, photos: usize, into: Option) -> Plan { + match into { + Some(Destination::Open) => Plan::Merge, + Some(Destination::New) => Plan::NewLibrary, + None if !on_disk || setting == LightroomImport::AlwaysMerge => Plan::Merge, + None => Plan::Ask { new_library: photos > 0 }, + } +} + +/// The photos in the open library (not those in Recently Deleted or browsed under Local). +fn library_photos(app: &LightcraftApp) -> usize { + app.session.catalog.photos().filter(|p| p.in_library()).count() +} + +/// The open library's folder, when it is saved on disk. +fn library_dir(app: &LightcraftApp) -> Option { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()) +} + +/// Where a new library goes by default: next to the open one (its parent folder). +pub fn default_location(app: &LightcraftApp) -> Option { + library_dir(app).and_then(|d| d.parent().map(Path::to_path_buf)) +} + +/// The new library's default name: the catalog's file name without `.lrcat` (`Travel.lrcat` → +/// `Travel`), or `Travel 2`, `Travel 3`… when `location` has something by that name already. +pub fn default_name(catalog: &Path, location: Option<&Path>) -> String { + let stem = catalog.file_stem().map(|s| s.to_string_lossy().trim().to_string()).filter(|s| !s.is_empty()); + let stem = stem.unwrap_or_else(|| "Lightroom Catalog".to_string()); + match location { + Some(loc) => crate::panels::settings::free_name(loc, &stem).file_name().map_or(stem.clone(), |n| n.to_string_lossy().into_owned()), + None => stem, + } +} + +/// Refuse while a Lightroom catalog task or another import runs (as the import itself would), +/// before a window asks a question whose answer couldn't be carried out. +fn busy(app: &LightcraftApp) -> Result<(), String> { + if crate::lightroom_import::is_running(app) { + return Err("a Lightroom catalog task is already running; wait for it to finish".into()); + } + if app.import.is_some() || app.scan.is_some() { + return Err("an import is running; wait for it to finish before importing a Lightroom catalog".into()); + } + Ok(()) +} + +/// `file.importLightroom` once the catalog `path` is known: merge, make a new library first, or +/// ask, as [`decide`] says. Returns the import's start (`{running, kind, path}`), with +/// `library: {path, name, photos}` when a new library was made, or `{asking: true, path, into}` +/// while the window asks (`into`: the choice it opens with). +pub fn import(app: &mut LightcraftApp, path: &str, req: &Request) -> Result { + // the browser build can't import a catalog at all: the import says so (and no library is made) + if cfg!(target_arch = "wasm32") { + return app.run("library.importLightroom", json!({"path": path})); + } + let on_disk = library_dir(app).is_some(); + match decide(app.ui.settings.lightroom_import, on_disk, library_photos(app), req.into) { + Plan::Merge => app.run("library.importLightroom", json!({"path": path})), + Plan::NewLibrary => into_new_library(app, path, req), + Plan::Ask { new_library } => { + busy(app)?; + if !Path::new(path).is_file() { + return Err(format!("{path}: there is no Lightroom catalog file here")); + } + let location = req.location.clone().or_else(|| default_location(app).map(|d| d.to_string_lossy().into_owned())).unwrap_or_default(); + let name = req.name.clone().unwrap_or_else(|| default_name(Path::new(path), (!location.is_empty()).then_some(Path::new(&location)))); + app.ui.dialog = Some(Dialog::LightroomDestination { + catalog: path.to_string(), + library: crate::panels::settings::open_library_name(app).unwrap_or_default(), + photos: library_photos(app), + new_library, + name, + location, + dont_ask: false, + generation: app.session.library_generation(), + }); + Ok(json!({"asking": true, "path": path, "into": if new_library { "new" } else { "open" }})) + } + } +} + +/// Make the new library (`app.newLibrary {path: location, name}`: its rules, its errors), open it +/// and import the catalog there. The catalog is checked first, so a mistyped path doesn't leave an +/// empty library behind. +fn into_new_library(app: &mut LightcraftApp, path: &str, req: &Request) -> Result { + busy(app)?; + if !Path::new(path).is_file() { + return Err(format!("{path}: there is no Lightroom catalog file here")); + } + let location = match req.location.as_deref().map(str::trim) { + Some("") => return Err("file.importLightroom: choose where to put the new library (`location`)".into()), + Some(l) => PathBuf::from(l), + None => default_location(app).ok_or("file.importLightroom: no library is open on disk to put the new one next to; give `location`")?, + }; + if !location.is_absolute() { + return Err(format!("file.importLightroom: {}: the location must be a full folder path", location.display())); + } + let name = req.name.clone().unwrap_or_else(|| default_name(Path::new(path), Some(&location))); + let library = app.run("app.newLibrary", json!({"path": location.to_string_lossy(), "name": name}))?; + let mut started = app.run("library.importLightroom", json!({"path": path}))?; + if let Some(o) = started.as_object_mut() { + o.insert("library".into(), library); + } + Ok(started) +} + +/// The window's choice as `file.importLightroom` params. +fn params(catalog: &str, new_library: bool, name: &str, location: &str) -> Value { + if new_library { json!({"path": catalog, "into": "new", "name": name, "location": location}) } else { json!({"path": catalog, "into": "open"}) } +} + +/// The window's confirm button: import as chosen. "Don't ask again" is kept once a merge has +/// started (it only applies to merging). Another library opened while the window was up (by an +/// agent, the control channel) makes the window stale for both choices: nothing is merged into +/// it, and no new library is made from defaults that described the library it replaced. +pub fn confirm(app: &mut LightcraftApp, dialog: &Dialog) -> Result { + let Dialog::LightroomDestination { catalog, new_library, name, location, dont_ask, generation, .. } = dialog else { + return Err("not the Lightroom catalog destination window".into()); + }; + if *generation != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since this window appeared: nothing was imported").to_string()); + } + let r = app.run("file.importLightroom", params(catalog, *new_library, name, location))?; + if !*new_library && *dont_ask { + app.ui.settings.lightroom_import = LightroomImport::AlwaysMerge; + } + Ok(r) +} + +/// After a failed confirm the window stays open to be corrected (a folder that isn't empty, a +/// name a folder can't have), unless the library it named has gone. +pub fn keeps_open_on_error(app: &LightcraftApp, dialog: &Dialog) -> bool { + matches!(dialog, Dialog::LightroomDestination { generation, .. } if *generation == app.session.library_generation()) +} + +/// The confirm button's label. +pub fn ok_label(dialog: &Dialog) -> &'static str { + match dialog { + Dialog::LightroomDestination { new_library: true, .. } => "Create Library and Import", + _ => "Import", + } +} + +/// The window's body: the two choices, the new library's name and location, and "Don't ask again". +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, dialog: &mut Dialog) { + let Dialog::LightroomDestination { catalog, library, photos, new_library, name, location, dont_ask, .. } = dialog else { return }; + let t = crate::theme::Tokens::get(ui.ctx()); + let tr = crate::i18n::tr; + let file = Path::new(catalog.as_str()).file_name().map_or_else(|| catalog.clone(), |n| n.to_string_lossy().into_owned()); + ui.label(crate::i18n::tr_format!("Where should the Lightroom catalog “{catalog}” go?", catalog = file)); + ui.label(egui::RichText::new(catalog.as_str()).color(t.text_dim)); + ui.add_space(4.0); + let n = *photos; + let merge = crate::i18n::tr_format!( + "Merge into the open library “{library}” ({count} photo{})", + if n == 1 { "" } else { "s" }, + library = library, + count = n + ); + let r = ui.radio_value(new_library, false, merge); + crate::widgets::register(ui.ctx(), "radio:lightroomDestination-open", r.rect); + let indent = ui.spacing().icon_width + ui.spacing().icon_spacing; + let note = |ui: &mut egui::Ui, text: &str| { + ui.horizontal(|ui| { + ui.add_space(indent); + ui.add(egui::Label::new(egui::RichText::new(text).color(t.text_dim)).wrap()); + }); + }; + note( + ui, + if n == 0 { + tr("The library is empty: the catalog's photos, collections and edits become its first.") + } else { + tr("The catalog's photos, collections and edits join the ones already in this library.") + }, + ); + let r = ui.radio_value(new_library, true, tr("Create a new library")); + crate::widgets::register(ui.ctx(), "radio:lightroomDestination-new", r.rect); + ui.add_enabled_ui(*new_library, |ui| { + // a field's caption, left-aligned in a column as wide as the longer one (in any language) + let font = egui::TextStyle::Body.resolve(ui.style()); + let caption_w = [tr("Library name"), tr("Location")] + .iter() + .map(|c| ui.painter().layout_no_wrap(c.to_string(), font.clone(), t.text_label).size().x) + .fold(60.0_f32, f32::max) + + 8.0; + let caption = |ui: &mut egui::Ui, text: &str| { + ui.allocate_ui_with_layout(egui::vec2(caption_w, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(caption_w); + ui.label(egui::RichText::new(text).color(t.text_label)); + }); + }; + ui.horizontal(|ui| { + ui.add_space(indent); + caption(ui, tr("Library name")); + crate::text_field::TextField::singleline("field:lightroomLibraryName", name) + .label(tr("Library name")) + .width(ui.available_width().max(120.0)) + .show(ui); + }); + ui.horizontal(|ui| { + ui.add_space(indent); + caption(ui, tr("Location")); + let choose = app.services.pick_folder.is_some(); + let button_w = if choose { 80.0 } else { 0.0 }; + crate::text_field::TextField::singleline("field:lightroomLibraryLocation", location) + .label(tr("Location")) + .width((ui.available_width() - button_w).max(120.0)) + .show(ui); + if choose + && crate::widgets::text_button(ui, "lightroomLibraryChoose", tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *location = d; + } + }); + note(ui, tr("LightCraft opens the new library, then imports the catalog into it. Its folder must be new or empty.")); + }); + ui.add_space(4.0); + // (it only speaks of merging: unticked and greyed out while a new library is chosen) + let mut ticked = *dont_ask && !*new_library; + let c = ui.add_enabled(!*new_library, egui::Checkbox::new(&mut ticked, tr("Don't ask again (always merge into the open library)"))); + crate::widgets::register(ui.ctx(), "check:lightroomDontAsk", c.rect); + if !*new_library { + *dont_ask = ticked; + } + ui.label(egui::RichText::new(tr("To be asked again: Settings → General → When importing a Lightroom catalog.")).size(11.0).color(t.text_dim)); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every combination of the setting, an empty or full library on disk or in memory, and `into`. + #[test] + fn the_setting_the_library_and_into_decide() { + use Destination::{New, Open}; + use LightroomImport::{AlwaysMerge, Ask}; + for setting in [Ask, AlwaysMerge] { + for on_disk in [true, false] { + for photos in [0, 5] { + // `into` always wins: the control channel acts without asking + assert_eq!(decide(setting, on_disk, photos, Some(Open)), Plan::Merge); + assert_eq!(decide(setting, on_disk, photos, Some(New)), Plan::NewLibrary); + } + } + } + // without it: ask, opening on Merge for an empty library and on a new library for a full one + assert_eq!(decide(Ask, true, 0, None), Plan::Ask { new_library: false }); + assert_eq!(decide(Ask, true, 5, None), Plan::Ask { new_library: true }); + // "Always merge" doesn't ask + assert_eq!(decide(AlwaysMerge, true, 0, None), Plan::Merge); + assert_eq!(decide(AlwaysMerge, true, 5, None), Plan::Merge); + // a library in memory has no folder to put a new one next to: it merges, as before + assert_eq!(decide(Ask, false, 0, None), Plan::Merge); + assert_eq!(decide(Ask, false, 5, None), Plan::Merge); + } + + /// `into`, `name` and `location` are read, and a wrong one is an error naming the command. + #[test] + fn params_are_checked_before_anything_happens() { + assert_eq!(Request::parse(&json!({})).unwrap(), Request::default()); + assert_eq!(Request::parse(&json!({"into": null})).unwrap(), Request::default()); + let r = Request::parse(&json!({"into": "new", "name": "Travel", "location": "/photos"})).unwrap(); + assert_eq!(r, Request { into: Some(Destination::New), name: Some("Travel".into()), location: Some("/photos".into()) }); + assert_eq!(Request::parse(&json!({"into": "open"})).unwrap().into, Some(Destination::Open)); + for bad in [json!({"into": "elsewhere"}), json!({"into": 1}), json!({"name": 3}), json!({"location": ["a"]})] { + let err = Request::parse(&bad).unwrap_err(); + assert!(err.starts_with("file.importLightroom:"), "{err}"); + } + } + + /// The catalog's file name, or the next free one in the location. + #[test] + fn the_new_library_is_named_after_the_catalog() { + assert_eq!(default_name(Path::new("/x/Travel.lrcat"), None), "Travel"); + assert_eq!(default_name(Path::new("/x/Lightroom Catalog-v13-3.lrcat"), None), "Lightroom Catalog-v13-3"); + // nothing to name it after + assert_eq!(default_name(Path::new(""), None), "Lightroom Catalog"); + // a name taken in the location: the next free one + let dir = std::env::temp_dir().join(format!("lc-ui-lrdest-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Travel")).unwrap(); + assert_eq!(default_name(Path::new("/x/Travel.lrcat"), Some(&dir)), "Travel 2"); + assert_eq!(default_name(Path::new("/x/Family.lrcat"), Some(&dir)), "Family"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 059a700e1..098c989d5 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -140,6 +140,22 @@ fn unsupported_wasm() -> Result { Err("Lightroom catalog import is unavailable in browser builds; open the catalog in the native LightCraft app".into()) } +/// Tests: a catalog import that never ends while the returned value lives, so a drop meets it pending. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn stall_import(app: &mut LightcraftApp) -> Box { + let (tx, rx) = mpsc::channel::(); + let task = LightroomTask::new( + &app.session, + Kind::Import, + PathBuf::from("catalog.lrcat"), + Arc::new(AtomicBool::new(false)), + LightroomProgress::default(), + rx, + ); + app.lightroom = Some(task); + Box::new(tx) +} + /// Whether a Lightroom task currently owns the catalog transition. pub fn is_running(app: &LightcraftApp) -> bool { app.lightroom.is_some() diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f315b105a..30a3b474b 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -48,6 +48,7 @@ pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a s if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) || (id == "app.openLibrary" && params.get("path").is_some()) + || (id == "app.recentLibraries" && params.get("remove").is_some()) { label } else { @@ -55,6 +56,67 @@ pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a s } } +/// Whether AppKit offers a shortcut to its menu instead of the window. Plain letters stay +/// with egui even when the native menu displays them as key equivalents. +pub fn native_menu_delivers(sc: &str) -> bool { + sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok())) +} + +/// Native label and accelerator for a menu row. A grid row may borrow the cycle key as a +/// hint, but must not consume a modified key and turn cycling into direct grid selection. +/// AppKit has no separate shortcut-hint column: borrowed native-owned keys are appended to +/// the translated label instead, leaving their dispatch with egui. Ordinary G stays in the +/// native key-equivalent column because AppKit never hands it to the menu. +pub fn native_item_presentation<'a>( + keymap: &crate::shortcuts::Keymap, + id: &str, + params: &Value, + label: &str, + shortcut: Option<&'a str>, +) -> (String, Option<&'a str>) { + let label = display_item_label(id, params, label); + let borrowed = matches!(id, "view.photoGrid" | "view.squareGrid") && shortcut != crate::shortcuts::shortcut_of(keymap, id); + match shortcut.filter(|sc| borrowed && native_menu_delivers(sc)) { + Some(sc) => (format!("{label} ({})", shortcut_text(sc, true)), None), + None => (label.to_string(), shortcut), + } +} + +/// Cache key for native menu structure and effective accelerator ownership. A transfer from +/// a direct grid binding to a borrowed cycle hint must rebuild native handles and republish +/// their owned shortcuts even when the visible menu model is identical. +pub fn native_menu_structure(bar: &[(String, Vec)], keymap: &crate::shortcuts::Keymap) -> String { + /// Add stable identities and effective native keys; live checked values stay outside the cache key. + fn walk(n: &[MenuNode], out: &mut String, keymap: &crate::shortcuts::Keymap) { + for x in n { + match x { + MenuNode::Item { id, params, label, checked, shortcut, .. } => { + out.push_str(&MenuNode::key(id, params)); + out.push(if checked.is_some() { 'c' } else { 'i' }); + out.push_str(shortcut.as_deref().unwrap_or("")); + // A key can change owners while both visible hints stay identical. + out.push('|'); + out.push_str(native_item_presentation(keymap, id, params, label, shortcut.as_deref()).1.unwrap_or("")); + } + MenuNode::Separator => out.push('-'), + MenuNode::Submenu { label, children } => { + out.push('['); + out.push_str(label); + walk(children, out, keymap); + out.push(']'); + } + } + out.push(';'); + } + } + let mut s = format!("{};", crate::i18n::language().code()); + for (t, items) in bar { + s.push_str(t); + walk(items, &mut s, keymap); + } + s +} + /// Top-level menus in order (the macOS app menu is added by the host). pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; @@ -342,6 +404,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.focusPoint" => Some(u.settings.focus_point), "view.zoomToFocus" => Some(u.settings.zoom_to_focus), "view.filmstrip" => Some(u.filmstrip), + "view.frameRate" => Some(u.frame_rate), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), "view.visualizeHdr" => Some(u.hdr_visualize), @@ -547,7 +610,7 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option> item( "view.layoutImage", Value::Null, - "Show Layout Image", + "Layout Image", crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.layoutImage"), chosen, Some(li.show && chosen), @@ -633,23 +696,28 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option> "Open Recent Library" => { let can = crate::panels::settings::can_switch_library(app).is_ok(); let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let mut v: Vec = app - .ui - .settings - .recent_libraries + let recent = &app.ui.settings.recent_libraries; + let label = |r: &str| { + let d = std::path::Path::new(r); + format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()) + }; + let mut v: Vec = recent .iter() .map(|r| { let d = std::path::Path::new(r); - // a missing library (moved, drive disconnected) is shown greyed out - let label = - format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()); - item("app.openLibrary", json!({"path": r}), label, None, can && d.is_dir(), Some(open.as_deref() == Some(d))) + // a library a check found missing (moved, drive disconnected) is greyed out; the + // check runs on a worker (`RecentChecks`), never here: the menu is rebuilt every frame + let gone = app.recent_checks.exists(r) == Some(false); + item("app.openLibrary", json!({"path": r}), label(r), None, can && !gone, Some(open.as_deref() == Some(d))) }) .collect(); if v.is_empty() { v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None)); } else { v.push(MenuNode::Separator); + // one entry at a time, missing ones too: only the list forgets it (issue #1290) + let forget = recent.iter().map(|r| item("app.recentLibraries", json!({"remove": r}), label(r), None, true, None)).collect(); + v.push(MenuNode::Submenu { label: "Remove from List".into(), children: forget }); v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None)); } v @@ -739,12 +807,14 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option> }) } +/// Resolve a live menu row, including contextual keys and borrowed grid-cycle hints. fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { // Shift+P picks and advances in Library; don't advertise it on Presets in those views. let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() }; // in an album the Delete key removes from the album (issue #1805): the menu says so let delete_key = crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "photo.delete"); let shortcut = match e.id.as_str() { + "view.photoGrid" | "view.squareGrid" => shortcut.or_else(|| crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.gridToggle")), "album.removePhotos" => delete_key, "photo.delete" if matches!(app.session.source, lightcraft_engine::LibrarySource::Album(_)) => None, _ => shortcut, @@ -863,9 +933,10 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result shortcut.clone(), + _ => panic!("grid row must be an item"), + } + }; + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id), None, "{id}"); + } + } + #[test] fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { let mut a = app(); @@ -1659,4 +1847,56 @@ mod tests { assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); assert_eq!(shortcut_text("Delete", true), "⌫"); } + + /// Every item of a menu, submenus included. + fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { + nodes + .iter() + .flat_map(|n| match n { + MenuNode::Submenu { children, .. } => flat(children), + MenuNode::Item { .. } => vec![n], + MenuNode::Separator => vec![], + }) + .collect() + } + + /// Given the menu bar, Then an item that turns something on or off is named for the thing, + /// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". + /// Show Original is a view (\), not a switch: it keeps Lightroom's name. + #[test] + fn toggles_are_named_for_the_thing_not_show() { + let mut a = app(); + a.ui.view = ViewMode::Detail; + let shows: Vec = menu_bar(&a) + .iter() + .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) + .filter_map(|n| match n { + MenuNode::Item { id, params, label, checked: Some(_), .. } + if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => + { + Some(format!("{id}: {label}")) + } + _ => None, + }) + .collect(); + assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); + } + + /// Given every command with a menu path, Then no two of them share a label, and none is named + /// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart + /// by name ("Detail" the view and "Detail" the Edit section were two things called the same). + #[test] + fn no_two_menu_commands_share_a_label() { + let entries = crate::menus::menu_entries(&app()); + let mut clashes = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if MENUS.contains(&e.label.as_str()) { + clashes.push(format!("{} is called like the {} menu", e.id, e.label)); + } + if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { + clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); + } + } + assert!(clashes.is_empty(), "{clashes:#?}"); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 747cfbccb..a162e6ca6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -78,13 +78,13 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.gridToggle", "Grid", Some("G"), ""), ("library.selectOnlyActive", "Select Only Active Photo", Some("Cmd+Shift+D"), "Edit"), ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), - ("view.detail", "Detail", Some("D"), "View"), + ("view.detail", "Detail View", Some("D"), "View"), ("view.compare", "Compare", Some("Shift+C"), "View"), ("view.survey", "Survey", Some("N"), "View"), ("view.people", "People", None, "View"), ("view.person", "Show Person", None, ""), ("view.faceBoxes", "Face Boxes", None, "View"), - ("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"), + ("view.focusPoint", "Focus Point", Some("Alt+F"), "View"), ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), @@ -95,7 +95,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), ("view.rightPanel", "Right Panel", None, "View"), - ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), + ("view.beforeAfter", "Before/After Side by Side", Some("Y"), "View"), ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), @@ -111,23 +111,26 @@ pub const UI_COMMANDS: &[UiCommand] = &[ // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), - ("view.clipping", "Show Clipping", Some("J"), "View"), + ("view.clipping", "Clipping", Some("J"), "View"), ("view.visualizeHdr", "Visualize HDR", None, "View"), // in grids S expands/collapses stacks (the engine command it shadows) ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), - ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + ("view.maskOverlay", "Mask Overlay", Some("O"), "View"), // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one - ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+Shift+O"), "View"), + ("view.maskOverlayAll", "All Mask Overlays", Some("Cmd+Shift+O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), - ("view.maskPins", "Show Mask Pins", None, "View"), + ("view.maskPins", "Mask Pins", None, "View"), // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool // Overlay and the Remove panel list the modes. ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), + // A while the Crop panel is open: the aspect lock as a first-class row. Visualize Spots keeps + // the key outside the panel; the two are a contextual pair in shortcuts::handle + ("crop.lockAspect", "Lock Aspect Ratio", Some("A"), ""), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), ("view.back", "Back to Grid", Some("Escape"), ""), @@ -142,11 +145,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), - ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImage", "Layout Image", Some("Cmd+Alt+O"), "View"), ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), ("view.layoutImageMatte", "Layout Image Matte", None, ""), ("view.navigator", "Navigator", None, "View"), - ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.edit", "Edit Panel", Some("E"), "Window"), ("panel.profiles", "Profile Browser", None, "Window"), ("panel.crop", "Crop & Rotate", Some("C"), "Window"), ("panel.remove", "Remove", Some("H"), "Window"), @@ -180,7 +183,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.smartAlbum", "New Smart Album…", None, "File"), - ("view.photoCounts", "Show Photo Counts", None, "View"), + ("view.photoCounts", "Photo Counts", None, "View"), ("view.sidebarSection", "Show Sidebar Section", None, ""), ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), ("view.sidebarReset", "Reset Sidebar Sections", None, ""), @@ -227,6 +230,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), ("file.addFolder", "Import from Folder…", None, "File"), + // {path?, into?: open | new, name?, location?}: asks where the catalog goes unless `into` (or + // Settings) says (#2672, `lightroom_destination`) ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), ("file.addFromDevice", "Import from Device", None, ""), ("file.findMissing", "Find Missing Photos…", None, "File"), @@ -256,6 +261,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.about", "About LightCraft", None, "Help"), ("app.systemInfo", "System Info…", None, "Help"), ("app.openLogFolder", "Open Log Folder", None, "Help"), + // the frame-rate display (`frame_rate`): a debugging aid, never saved + ("view.frameRate", "Frame Rate", None, "Help>Debug"), ("app.whatsNew", "What's New", None, "Help"), ("dialog.cull", "Assisted Culling…", None, "Photo"), ("app.help", "LightCraft Help", Some("F1"), "Help"), @@ -839,6 +846,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {show?: bool} (toggles when omitted) + app.ui.frame_rate = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.frame_rate); + Ok(json!({"show": app.ui.frame_rate})) + } "view.leftPanel" => { app.ui.left_panel = !app.ui.left_panel; Ok(Value::Null) @@ -1125,6 +1137,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option app.run("crop.aspect", json!({"aspect": "toggle"})), "view.cropOverlay" => { use crate::state::CropOverlay::*; app.ui.crop_overlay = match app.ui.crop_overlay { @@ -1394,7 +1408,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let time = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.date().replace('T', " ")).unwrap_or_default(); + // a time that doesn't fully read isn't offered as the starting point (`10:0` would read as midnight) + let time = app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .map(|p| p.date()) + .filter(|d| lightcraft_catalog::dates::shift_iso(d, 0).is_some()) + .map(|d| d.replace('T', " ")) + .unwrap_or_default(); let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); app.ui.dialog = Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); @@ -1523,7 +1545,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option edit_in(app, &ctx, p, false), "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), "dialog.cull" => { - app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); + let (burst_gap, similarity) = app.ui.cull_burst.unwrap_or((10.0, 0.93)); + app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true, burst_gap, similarity }); Ok(Value::Null) } "app.whatsNew" => { @@ -1614,6 +1637,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // where the catalog goes (#2672): checked before a file dialog opens + let req = match crate::lightroom_destination::Request::parse(p) { + Ok(req) => req, + Err(e) => return Some(Err(e)), + }; let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { Some(path) => Some(path), None => { @@ -1626,7 +1654,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option app.run("library.importLightroom", json!({"path":path})), + Some(path) => crate::lightroom_destination::import(app, &path, &req), None => Ok(Value::Null), } } @@ -2407,8 +2435,14 @@ fn show_in_folders(app: &mut LightcraftApp, id: Option Result { + // every way in (menu, shortcut, context menu, the control channel) says why nothing opened + edit_in_inner(app, ctx, p, photocraft).inspect_err(|e| app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e))) +} + +fn edit_in_inner(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { let settings = &app.ui.settings; let (file, configured) = if photocraft { let path = settings.photocraft_path.trim(); @@ -2417,11 +2451,8 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: (settings.external_file, settings.external_editor.clone()) }; let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); - if let Some(check) = app.services.check_editor.as_ref() - && let Err(e) = check(&editor) - { - app.toast(ctx, e.clone()); - return Err(e); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; } let mut params = file.params(); if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { @@ -2451,9 +2482,18 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: } } } - match (failed, copies.as_slice()) { - (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), - (None, [(path, _)]) => { + let mut r = r; + if let Some(o) = r.as_object_mut() { + o.insert("failed".into(), json!(failed)); + } + if let Some(e) = failed { + // the copies are in the library already (one undo step): an error would invite a retry + // that makes another set, so the result says what failed and the toast says why + app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); + return Ok(r); + } + match copies.as_slice() { + [(path, _)] => { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index 61485a271..13aad67a6 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -104,6 +104,9 @@ pub struct MergeState { pub error: Option, /// Options whose preview failed (not retried until they change). pub failed_options: Option, + /// The preview zoomed in by a click: the point of it (0..1 across and down) at the middle of + /// the box. `None` fits it in the box. Kept while the options change, to compare them there. + pub zoom: Option<(f32, f32)>, /// The last finished merge (`{id, path, …}`). pub last_result: Option, /// Photos the dialog merges (the selection when it opened). @@ -164,6 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result { app.merge.error = None; app.merge.preview = None; app.merge.preview_pixels = None; + app.merge.zoom = None; if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } @@ -365,6 +369,56 @@ fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { app.merge.failed_options.as_ref() == Some(opts) } +/// Where a preview of `size` pixels is drawn in `area`: fitted, or zoomed in on the point `zoom` +/// to one preview pixel per screen pixel (at least twice the fitted size), kept over the box +/// where it covers it. Returns the rectangle and the point actually at the middle. +fn preview_rect(area: egui::Rect, size: [usize; 2], ppp: f32, zoom: Option<(f32, f32)>) -> (egui::Rect, Option<(f32, f32)>) { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let fit = (area.width() / w).min(area.height() / h); + let Some((u, v)) = zoom else { + return (egui::Rect::from_center_size(area.center(), egui::vec2(w * fit, h * fit)), None); + }; + let s = (1.0 / ppp.max(0.1)).max(fit * 2.0); + let img = egui::vec2(w * s, h * s); + // the point clicked to the middle, but no gap at an edge where the image covers the box + let axis = |t: f32, len: f32, lo: f32, centre: f32, span: f32| -> (f32, f32) { + if len <= span { + return (centre - len / 2.0, 0.5); + } + let start = (centre - t.clamp(0.0, 1.0) * len).clamp(lo + span - len, lo); + (start, (centre - start) / len) + }; + let (x, u) = axis(u, img.x, area.left(), area.center().x, area.width()); + let (y, v) = axis(v, img.y, area.top(), area.center().y, area.height()); + (egui::Rect::from_min_size(egui::pos2(x, y), img), Some((u, v))) +} + +/// Click on the preview: zoom in on the point clicked, or back out; drag pans while zoomed in. +fn preview_input(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img: egui::Rect) { + let zoomed = app.merge.zoom.is_some(); + if resp.hovered() { + let cursor = match (zoomed, resp.dragged()) { + (false, _) => egui::CursorIcon::ZoomIn, + (true, false) => egui::CursorIcon::Grab, + (true, true) => egui::CursorIcon::Grabbing, + }; + ui.ctx().set_cursor_icon(cursor); + } + if resp.clicked() { + app.merge.zoom = if zoomed { + None + } else { + resp.interact_pointer_pos() + .map(|q| (((q.x - img.left()) / img.width()).clamp(0.0, 1.0), ((q.y - img.top()) / img.height()).clamp(0.0, 1.0))) + }; + } else if resp.dragged() + && let Some((u, v)) = app.merge.zoom + { + let d = resp.drag_delta(); + app.merge.zoom = Some((u - d.x / img.width(), v - d.y / img.height())); + } +} + /// The dialog body (options on the left, preview on the right). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) { let t = crate::theme::Tokens::get(ui.ctx()); @@ -372,13 +426,15 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) // preview ui.vertical(|ui| { let box_size = egui::vec2(520.0, 360.0); - let (rect, _) = ui.allocate_exact_size(box_size, egui::Sense::hover()); + let (rect, resp) = ui.allocate_exact_size(box_size, egui::Sense::click_and_drag()); ui.painter().rect_filled(rect, 4.0, t.canvas); crate::widgets::register(ui.ctx(), "merge.preview", rect); if let Some((tex, size, _, _)) = &app.merge.preview { - let s = (box_size.x / size[0] as f32).min(box_size.y / size[1] as f32); - let r = egui::Rect::from_center_size(rect.center(), egui::vec2(size[0] as f32 * s, size[1] as f32 * s)); - ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + let (tex, size) = (tex.id(), *size); + let (r, zoom) = preview_rect(rect, size, ui.ctx().pixels_per_point(), app.merge.zoom); + app.merge.zoom = zoom; + ui.painter_at(rect).image(tex, r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + preview_input(app, ui, &resp, r); } if let Some(task) = &app.merge.preview_task { let (f, stage) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); @@ -497,6 +553,46 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) - mod tests { use super::*; + fn area() -> egui::Rect { + egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(520.0, 360.0)) + } + + #[test] + fn the_preview_fits_until_clicked() { + let (r, zoom) = preview_rect(area(), [1024, 683], 1.0, None); + assert_eq!(zoom, None); + assert!((r.width() - 520.0).abs() < 0.5 && r.height() < 360.0 && (r.center() - area().center()).length() < 0.5, "{r:?}"); + } + + /// Like Lightroom's: a click zooms in to one preview pixel per screen pixel on the point clicked. + #[test] + fn a_click_zooms_in_on_the_point_clicked() { + // a panorama preview, wide enough that one pixel per pixel is more than twice the fitted size + let (r, zoom) = preview_rect(area(), [1600, 1000], 1.0, Some((0.4, 0.55))); + assert_eq!(r.size(), egui::vec2(1600.0, 1000.0)); + let (u, v) = zoom.unwrap(); + assert!((u - 0.4).abs() < 1e-4 && (v - 0.55).abs() < 1e-4, "{u} {v}"); + let at = r.min + egui::vec2(r.width() * 0.4, r.height() * 0.55); + assert!((at - area().center()).length() < 0.5, "the point clicked is in the middle: {at:?}"); + // on a Retina screen, one preview pixel per physical pixel, but always at least twice the fitted size + let (r, _) = preview_rect(area(), [1024, 683], 2.0, Some((0.5, 0.5))); + assert!((r.width() - 1040.0).abs() < 0.5, "{r:?}"); + } + + /// Zoomed in near a corner (or panned past it), the image still covers the box. + #[test] + fn a_zoomed_preview_stays_over_the_box() { + for p in [(0.0, 0.0), (1.0, 1.0), (-3.0, 7.0)] { + let (r, zoom) = preview_rect(area(), [1024, 683], 1.0, Some(p)); + assert!(r.contains_rect(area()), "{p:?}: {r:?}"); + let (u, v) = zoom.unwrap(); + assert!((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v), "{p:?}: {u} {v}"); + } + // a wide panorama is shorter than the box: centred up and down + let (r, _) = preview_rect(area(), [1024, 120], 1.0, Some((0.9, 0.0))); + assert!((r.center().y - area().center().y).abs() < 0.5 && r.left() < area().left() && r.right() >= area().right(), "{r:?}"); + } + /// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so. #[test] fn a_smaller_panorama_says_why() { diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 2e4137fda..7fa9570fe 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -3,7 +3,7 @@ //! that can be cancelled. The tasks come from the engine's registry ([`lightcraft_engine::activity`]); ✕ runs //! `activity.cancel` like any other frontend would. -use egui::{Align, Layout, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; +use egui::{Align, Layout, Margin, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_engine::activity::{TaskInfo, Unit}; use serde_json::json; @@ -136,7 +136,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { clicked = resp.on_hover_text(tr("Cancel")).clicked(); } }); - bar(ui, t, task, width); + bar(ui, task, width); let line = detail_line(task); if !line.is_empty() { ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate()); @@ -146,21 +146,11 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { clicked } -/// The progress bar; while the amount of work isn't known, a segment sweeps across. -fn bar(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) { - let (rect, _) = ui.allocate_exact_size(vec2(width, 5.0), Sense::hover()); - let p = ui.painter(); - p.rect_filled(rect, 2.5, t.field); - let fill = if task.total > 0 { - let frac = (task.done as f64 / task.total as f64).clamp(0.0, 1.0) as f32; - Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height())) - } else { - let phase = (ui.input(|i| i.time) * 0.7).fract() as f32; - let w = rect.width() * 0.3; - let x = rect.left() + (rect.width() + w) * phase - w; - Rect::from_min_max(pos2(x.max(rect.left()), rect.top()), pos2((x + w).min(rect.right()), rect.bottom())) - }; - if fill.width() > 0.0 { - p.rect_filled(fill, 2.5, if task.cancelling { t.text_dim } else { t.accent }); - } +/// The progress bar, named after its task for screen readers; while the amount of work isn't +/// known, a segment sweeps across. +pub(crate) fn bar(ui: &mut egui::Ui, task: &TaskInfo, width: f32) -> egui::Response { + let fraction = (task.total > 0).then(|| (task.done as f64 / task.total as f64) as f32); + let response = crate::progress::Progress::new(fraction).width(width).dimmed(task.cancelling).show(ui); + crate::widgets::screen_reader_name(ui.ctx(), response.id, tr(&task.label)); + response } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 69b76bcb8..415e23d06 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -8,7 +8,7 @@ use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::state::{ViewMode, Zoom}; use crate::theme::Tokens; -use crate::widgets::{icon_button, register, stars}; +use crate::widgets::{icon_button, register}; const VIEW_MODES: &[(&str, Icon, ViewMode, &str)] = &[ ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), @@ -105,7 +105,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); pill_ui.spacing_mut().item_spacing.x = 3.0; let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); - if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { + if let Some(r) = crate::rating::Rating::new("bottom", rating).show(&mut pill_ui) { let mut params = json!({"rating": r}); crate::panels::compare::target_active(app, &mut params); let _ = app.run("photo.rate", params); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index bb7937640..70037a51a 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -164,11 +164,13 @@ pub fn survey_step(app: &mut LightcraftApp, d: isize) -> Result { } /// After rating/flagging with Auto Advance: next candidate (Compare), next photo in the survey, -/// else the next photo in the view. -pub fn advance(app: &mut LightcraftApp) { +/// else the next photo in the view. `was` is the active photo before the rating: a rating that took +/// it out of the view made the photo after it active already, and that is the next one (#2312). +pub fn advance(app: &mut LightcraftApp, was: Option) { let _ = match app.ui.view { ViewMode::Compare => compare_step(app, 1), ViewMode::Survey => survey_step(app, 1), + _ if app.session.active() != was => Ok(Value::Null), _ => app.session.execute("library.next", &json!({})).map_err(|e| e.to_string()), }; } @@ -279,7 +281,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan); p.image(tex.tex.id(), display, uv, Color32::WHITE); } else { - p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38)); + p.rect_filled(img.intersect(img_area), 0.0, Tokens::get(ui.ctx()).skeleton); } if photo.flag == Flag::Reject { p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); @@ -343,7 +345,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let filled = i < photo.rating; let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0)); if r.right() < cap.right() { - paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); + crate::rating::paint_star(pt, r, filled, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); } } x += 5.0 * 13.0 + 4.0; diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 437a378a7..bcd77176a 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -141,6 +141,23 @@ pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on }); } +/// The bar under the Denoise slider while a photo's picture is made: at most 240 px wide, and +/// sweeping while the number of tiles is not known yet. The percentage is `progress::percent`, as the +/// bar's accessible value is: 100 % only once done. +pub(crate) fn detail_bar(ui: &mut egui::Ui, done: u64, total: u64) -> egui::Response { + let fraction = crate::progress::fraction_of(done, total); + let text = match fraction { + Some(f) => tr_format!("Making the denoised picture… {}%", crate::progress::percent(f)), + None => tr("Starting…").to_string(), + }; + crate::progress::Progress::new(fraction).label(text).width(ui.available_width().min(240.0)).widget("denoise:progress").show(ui) +} + +/// The bar in Settings, AI Denoise: 380 px wide however wide the tab is. +pub(crate) fn settings_bar(ui: &mut egui::Ui, done: u64, total: u64, text: String) -> egui::Response { + crate::progress::Progress::new(crate::progress::fraction_of(done, total)).label(text).width(380.0).widget("denoise:settingsProgress").show(ui) +} + /// The line under the Denoise slider for the photo `id`, whose Amount is `amount`. pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, amount: f64) { let t = Tokens::get(ui.ctx()); @@ -168,7 +185,12 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am let now = ui.ctx().input(|i| i.time); app.caches.denoise.next_pump = app.caches.denoise.next_pump.min(now + 0.05); ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - egui::Frame::NONE.inner_margin(pad).show(ui, |ui| note(ui, "Waiting to start…", t.text_dim)); + egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + note(ui, "Waiting to start…", t.text_dim); + }) + }); } else if amount > 0.0 { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { note(ui, "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", t.text_dim); @@ -193,14 +215,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am } PhotoState::Running { done, total } => { egui::Frame::NONE.inner_margin(pad).show(ui, |ui| { - let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; - let text = if total > 0 { - tr_format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32) - } else { - tr("Starting…").to_string() - }; - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(ui.available_width().min(240.0)).text(RichText::new(text).size(11.0))); - register(ui.ctx(), "denoise:progress", bar.rect); + detail_bar(ui, done as u64, total as u64); }); } PhotoState::Failed { why, unsupported } => { @@ -245,8 +260,12 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { .map(|v| v["downloads"].as_array().cloned().unwrap_or_default()) .unwrap_or_default(); heading(ui, t, "Models"); - let r = ui.add_enabled(runtime && app.services.pick_denoise_model.is_some(), egui::Button::new(tr("Install from file…"))); - register(ui.ctx(), "denoise:installFile", r.rect); + let installing = downloads.iter().any(|d| d["id"] == "local-install" && d["state"].as_str() == Some("installing")); + let r = crate::button::Button::new("Install from file…") + .enabled(runtime && app.services.pick_denoise_model.is_some()) + .busy(installing) + .widget("denoise:installFile") + .show(ui); if r.clicked() && let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next()) { @@ -321,7 +340,6 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); match running { Some((done, total)) => { - let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let name = status["running"]["photo"] .as_u64() .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) @@ -333,8 +351,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V done = done, total = total ); - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(380.0).text(RichText::new(text).size(11.5))); - register(ui.ctx(), "denoise:settingsProgress", bar.rect); + settings_bar(ui, done, total, text); } None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), None => { @@ -438,17 +455,22 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match (dl_state, dl) { (Some("running"), Some(d)) => { let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); - let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { tr("Checking…").to_string() } else { tr_format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)), host = host) }; - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5)))); - register(ui.ctx(), format!("denoise:progress:{id}"), bar.rect); + crate::progress::Progress::new(crate::progress::fraction_of(got, total)) + .label(text) + .width(crate::progress::ROW_WIDTH.min(ui.available_width())) + .widget(format!("denoise:progress:{id}")) + .show(ui); } (Some("done" | "installing"), _) => { - ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + }); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 082508d08..e655e4969 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -187,6 +187,20 @@ pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: e let _ = app.run("view.navigate", json!({"pan": pan})); } +/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is +/// active, so the healing brush, mask brushes and crop never paint or move while you look around; +/// the hand cursor shows while it does. +fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool { + if !resp.dragged_by(egui::PointerButton::Middle) { + return false; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 { + pan_image(app, area, img, resp.drag_delta()); + } + true +} + /// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. fn wheel_notches(events: &[egui::Event]) -> f32 { events @@ -306,7 +320,7 @@ pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) } -/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner /// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. /// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:`). pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { @@ -340,13 +354,16 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } -/// Whether an overlay can be rendered on a region tile without changing what the tile means. -/// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask -/// alphas as the full render; the other diagnostic overlays either need whole-frame context or -/// deliberately replace the loupe. +/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) +/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize +/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high +/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin +/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - use lightcraft_pipeline::Overlay; - matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. }) + use lightcraft_pipeline::Overlay as O; + match overlay { + O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, + } } /// What a window render of the loupe needs to know about the view it belongs to. @@ -532,7 +549,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut navigating = false; for &view_area in &areas { let view_img = in_pane(view_area, img_rect); - if navigate_gesture(app, ui, &resp, view_area, view_img, native) { + if navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) { navigating = true; target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); img_rect = target_rect; @@ -548,7 +565,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) let gpu = interacting - && lightcraft_engine::gpu::handles(lightcraft_pipeline::OutputDepth::U8, app.ui.soft_proof, overlay) + && lightcraft_engine::gpu::handles( + lightcraft_pipeline::OutputDepth::U8, + app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), + overlay, + ) && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); @@ -988,7 +1009,7 @@ fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) - fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { let Some(lines) = info_overlay_lines(app, photo) else { return }; let t = Tokens::get(p.ctx()); - let mut y = canvas.top() + INFO_OVERLAY_TOP; + let mut y = canvas.top() + info_overlay_top(app, &t); for (i, l) in lines.iter().enumerate() { // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white // layout made the shadow a second white copy (#1690) @@ -999,10 +1020,12 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li p.galley(at, g.clone(), Color32::WHITE); y += g.size().y + INFO_OVERLAY_GAP; } + y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } -/// Where the info overlay's text starts below the canvas' top, and the gap after each line. +/// Where the info overlay's text starts below the canvas' top (without window controls over it), +/// and the gap after each line. const INFO_OVERLAY_TOP: f32 = 12.0; const INFO_OVERLAY_GAP: f32 = 3.0; @@ -1011,14 +1034,22 @@ fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId { if i == 0 { t.semibold(15.0) } else { t.font(12.5) } } -/// How far below the canvas' top the info overlay's text reaches (0 when it is off), so a label -/// drawn in that corner can sit below it. +/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a +/// label drawn in that corner can sit below it. fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 { let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 }; let t = Tokens::get(p.ctx()); - lines.iter().enumerate().fold(INFO_OVERLAY_TOP, |y, (i, l)| { + let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP - }) + }); + text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } +} + +/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top +/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). +fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { + let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; + if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } } /// The info overlay's lines for `photo`, or `None` when it is off. @@ -1036,6 +1067,45 @@ fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Some(lines) } +/// The height of the info overlay's rating and flag row. +const OVERLAY_BADGES_H: f32 = 17.0; + +fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { + photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None +} + +/// The star rating and flag under the info overlay's lines, when the photo has either; returns +/// the y below them. +fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let flag = match photo.flag { + Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), + Flag::Reject => Some((Icon::FlagReject, t.reject)), + Flag::None => None, + }; + if !has_overlay_badges(photo) { + return at.y; + } + let stars = photo.rating.min(5); + let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; + icons.extend(flag); + let side = 12.0; + let mut x = at.x; + for (i, (icon, color)) in icons.into_iter().enumerate() { + // the flag stands a little apart from the stars + if i > 0 && i == stars as usize { + x += 5.0; + } + let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); + paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); + paint(p, r, icon, color); + x += side + 2.0; + } + register(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); + at.y + OVERLAY_BADGES_H +} + /// What the user did to a face box this frame. enum RegionEdit { /// The × was clicked. @@ -1323,7 +1393,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); match tex { Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), - None => p.rect_filled(frame, 0.0, Color32::from_gray(40)), + None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton), }; // the visible part of the photo, in normalized image coordinates let vis = canvas.intersect(img); @@ -2847,7 +2917,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { continue; } let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click()); - crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default()); + let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some(); + crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed)); register(ui.ctx(), format!("film:{}", id.0), cr); let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; @@ -2870,7 +2941,14 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); - if let Some(tex) = app.renderer.thumb(*id) { + let thumb = app.renderer.thumb(*id); + if thumb.is_none() { + p.rect_filled(img_area, 0.0, t.skeleton); + if failed { + p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text); + } + } + if let Some(tex) = thumb { let [tw, th] = tex.size; let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); @@ -2938,6 +3016,17 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }); } +/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture +/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the +/// catalog no longer has the photo (or its file name is blank); the reason then stands alone. +pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String { + match (file_name.is_empty(), failed) { + (_, false) => file_name.to_string(), + (true, true) => crate::i18n::tr("This photo can't be opened").to_string(), + (false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")), + } +} + /// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { use crate::icons::{Icon, paint}; @@ -2950,10 +3039,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); let y = bar.center().y; let mut x = bar.left() + 3.0; - for i in 0..ph.rating { - paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star); - } - x += ph.rating as f32 * 9.0 + 2.0; + x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; match ph.flag { Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), @@ -3473,4 +3559,83 @@ mod info_overlay_tests { app.ui.info_overlay = InfoOverlay::Basic; assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); } + + fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + (app, photo) + } + + /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. + fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + (all, crate::widgets::take_registry(&ctx)) + } + + fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { + let (texts, _) = paint_overlay(app, photo); + texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) + } + + /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the + /// file name starts below them; in macOS full screen they are gone and it keeps the corner. + #[test] + fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { + let (mut app, photo) = demo_app(); + app.ui.fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); + app.integrated_titlebar = true; + let bar = Tokens::get(&egui::Context::default()).top_bar_h; + assert!(name_top(&app, &photo) >= bar, "below the window controls"); + app.window_is_fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); + app.window_is_fullscreen = false; + app.ui.fullscreen = false; + assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); + } + + /// #2460: a rated or flagged photo shows its stars and flag under the date and size. + #[test] + fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { + let (_, mut photo) = demo_app(); + let (app, _) = demo_app(); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::None; + let badges = |photo: &lightcraft_catalog::Photo| { + paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) + }; + assert_eq!(badges(&photo), None, "nothing to show"); + photo.rating = 3; + let stars = badges(&photo).expect("three stars"); + photo.flag = lightcraft_catalog::Flag::Pick; + let flagged = badges(&photo).expect("three stars and a flag"); + assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); + let (texts, _) = paint_overlay(&app, &photo); + let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); + assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut extent = 0.0; + ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); + assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::Reject; + assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); + } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c24f6da7c..f14d7338b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -133,7 +133,14 @@ fn watermark_preview( Ok(img) => img, Err(e) => { let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; - let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + let r = ui + .horizontal(|ui| { + if e.is_none() { + crate::progress::spinner(ui, 12.0); + } + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)) + }) + .inner; if let Some(e) = e { r.on_hover_text(e); } @@ -261,6 +268,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::MovePhotos { .. } => "Move Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", + Dialog::LightroomDestination { .. } => "Import Lightroom Catalog", Dialog::SaveEffect { .. } => "New Effect Preset", Dialog::ManageEffects { .. } => "Manage Effect Presets", Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset", @@ -300,6 +308,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let widest = if settings { ctx.data(|d| d.get_temp::(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; let mut content_w = 0.0f32; let mut body_h = None; + // before the window: drawing it closes a dropdown the click lands outside of + let popup_was_open = egui::Popup::is_any_open(ctx); let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) @@ -315,6 +325,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, + Dialog::LightroomDestination { .. } => 480.0, Dialog::ManageEffects { .. } => 560.0, _ => 380.0, }) @@ -328,7 +339,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .logarithmic(true) .smallest_positive(1.0) .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) - .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), + .custom_parser(crate::panels::dialogs::parse_gap), ); crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); @@ -337,7 +348,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim), ); } - Dialog::Cull { reject_below, pick_best } => { + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { let n = app.session.selection.ids.len(); let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) }; ui.label(egui::RichText::new(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label)); @@ -347,6 +358,31 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." })).color(t.text_dim)); let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst")); crate::widgets::register(ui.ctx(), "check:cullPick", r.rect); + ui.add_space(6.0); + // the sliders drag over the usual range; their value fields take whatever + // `photo.analyze` accepts (a gap of 0 s to a day, a similarity of 0 to 1) + let r = ui.add( + egui::Slider::new(burst_gap, 1.0..=600.0) + .logarithmic(true) + .smallest_positive(1.0) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: shots at most this far apart")) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(crate::panels::dialogs::parse_gap), + ); + crate::widgets::register(ui.ctx(), "field:cullGap", r.rect); + let r = ui.add( + egui::Slider::new(similarity, 0.70..=0.99) + .clamping(egui::SliderClamping::Never) + .text(crate::i18n::tr("Burst: how alike (loose … strict)")) + // a typed 0.995 is kept (whole hundredths would round it to 1.00) + .step_by(0.001) + .min_decimals(2) + .max_decimals(3), + ); + crate::widgets::register(ui.ctx(), "field:cullSimilar", r.rect); + clamp_cull_burst(burst_gap, similarity); + ui.label(egui::RichText::new(crate::i18n::tr("Lower it when a panning or moving burst splits into several groups.")).color(t.text_dim)); ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); } Dialog::WhatsNew => { @@ -523,10 +559,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { "shift" => params["shift"].as_i64(), _ => Some((*zone as f64 * 3600.0).round() as i64), }; - let after = delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)); + // the photo's own time is the problem (a Lightroom catalog's capture time + // is copied verbatim), not what was typed + let unreadable = lightcraft_catalog::dates::shift_iso(&cur, 0).is_none(); + let after = match (mode.as_str(), unreadable && n == 1) { + // alone, it simply gets the time typed (photo.setCaptureTime repairs it) + ("set", true) => lightcraft_catalog::dates::normalize_iso(time), + _ => delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)), + }; ui.label( egui::RichText::new(match after { Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), + None if unreadable && !(mode == "set" && n == 1) => crate::i18n::tr("The active photo's capture time can't be read").to_string(), None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), }) .color(t.text_label), @@ -611,7 +655,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } None => { - ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim)); + }); } } let more = total.saturating_sub(RENAME_PREVIEW_ROWS); @@ -1084,7 +1131,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::Merge { opts } => crate::merge::body(app, ui, opts), Dialog::Import { opts } => crate::import::body(app, ui, opts), - Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), + // (Settings is never taller than `body_max`, so the scroll area round it never + // scrolls: its tab bar stays put and its pages scroll by themselves) + Dialog::Settings { tab } => crate::panels::settings::window(app, ui, tab, body_max), Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), Dialog::KeywordTag { editing, @@ -1244,13 +1293,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LightroomCatalog { folder, catalog } => { ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); ui.label(egui::RichText::new(catalog.as_deref().unwrap_or(folder.as_str())).color(t.text_dim)); - ui.label( - egui::RichText::new(crate::i18n::tr( - "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.", - )) - .color(t.text_dim), - ); + // (the import then asks where the catalog goes, unless Settings says to merge, #2672) + let asks = app.ui.settings.lightroom_import == crate::state::LightroomImport::Ask + && app.session.library.as_ref().is_some_and(|l| l.on_disk); + let what = if asks { + "The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library." + } else { + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now." + }; + ui.label(egui::RichText::new(crate::i18n::tr(what)).color(t.text_dim)); } + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::body(app, ui, d), Dialog::RemoveFolder { name, count, path, disk } => { ui.label(crate::i18n::tr_format!( "Remove “{name}” and its {count} photo{} from the library?", @@ -1368,6 +1421,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…", + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::ok_label(d), Dialog::SynchronizeFolder { .. } if informational => "Close", Dialog::SynchronizeFolder { .. } => "Synchronize", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", @@ -1437,6 +1491,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if settings && content_w > widest + 0.5 { ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); } + if let Some(w) = &shown { + // (the import review keeps its hand-picked photos however the click lands) + if app.ui.settings.close_dialogs_outside && !import && clicked_backdrop(ctx, w.response.rect, popup_was_open) { + close = true; + } + } if let Some(w) = shown { ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); if let Some(body_h) = body_h { @@ -1457,7 +1517,8 @@ 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), and so - // does Export (e.g. no folder chosen) so the choices aren't lost + // does Export (e.g. no folder chosen) so the choices aren't lost, and Edit Capture Time + // (a time that isn't a date, or a shift it refuses) Err(e) if matches!( dlg, @@ -1473,6 +1534,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::KeywordSet { .. } | Dialog::SynchronizeFolder { .. } | Dialog::SaveEffect { .. } + | Dialog::CaptureTime { .. } ) => { app.toast(ctx, e) @@ -1500,6 +1562,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, e); close = true; } + // photos whose capture time doesn't read are left as they were: say so + Ok(r) if matches!(dlg, Dialog::CaptureTime { .. }) && r["unreadable"].as_u64().is_some_and(|n| n > 0) => { + app.toast(ctx, crate::i18n::tr("Photos whose capture time can't be read were left as they were")); + close = true; + } + // where a Lightroom catalog goes: stays open to be corrected (a folder that isn't + // empty), unless the library it names has gone + Err(e) if matches!(dlg, Dialog::LightroomDestination { .. }) => { + close = !crate::lightroom_destination::keeps_open_on_error(app, &dlg); + app.toast(ctx, e); + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1512,9 +1585,50 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if app.ui.dialog.as_ref().is_some_and(|now| *now != at_start) { return; } + if !close { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(BACKDROP_SHOWN), frame)); + } app.ui.dialog = if close { None } else { Some(dlg) }; } +/// The last frame a dialog was drawn and stayed open, for [`clicked_backdrop`]. +const BACKDROP_SHOWN: &str = "dialog-backdrop-shown"; + +/// A click on the dimmed backdrop around the dialog: it closes the dialog like Esc. Pressed and +/// released outside the window, so a slider dragged past its edge doesn't close it; a press while a +/// popup is open (a dropdown, a date picker's calendar) only closes the popup: `popup_was_open` is +/// read before the window is drawn, since drawing it already closes the popup. Only a press made +/// while the dialog is shown counts: in the frame a dialog appears, its input is the click that +/// opened it (a quick click on a slow frame presses and releases in that one frame), so it never +/// closes it. (Read from the raw events: egui forgets the press origin on release.) +fn clicked_backdrop(ctx: &egui::Context, window: egui::Rect, popup_was_open: bool) -> bool { + let press_id = egui::Id::new("dialog-backdrop-press"); + let frame = ctx.cumulative_frame_nr(); + // the same frame too: a second pass of it gets no input + let shown = ctx.data(|d| d.get_temp::(egui::Id::new(BACKDROP_SHOWN))).is_some_and(|f| f + 1 >= frame); + if !shown { + ctx.data_mut(|d| d.remove_temp::(press_id)); + return false; + } + let (press, release, clicked) = ctx.input(|i| { + let at = |down: bool| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } if *pressed == down => Some(*pos), + _ => None, + }) + }; + (at(true), at(false), i.pointer.primary_clicked()) + }); + if let Some(p) = press { + let outside = !window.contains(p) && !popup_was_open; + ctx.data_mut(|d| d.insert_temp(press_id, outside)); + } + let Some(release) = release else { return false }; + let pressed_outside = ctx.data_mut(|d| d.remove_temp::(press_id)).unwrap_or(false); + clicked && pressed_outside && !window.contains(release) +} + /// Room kept free above and below a dialog (#781). const DIALOG_SCREEN_MARGIN: f32 = 12.0; /// A dialog's title bar, frame and button row before the first frame measures them. @@ -1576,6 +1690,61 @@ fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: i } /// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// What [`fmt_gap`] writes, read back: `90`, `90 s`, `1 min 30 s`, `2 h 5 min`, `1 day` +/// (units `s`, `min`, `h`, `day`, each optional after a plain number of seconds). +pub fn parse_gap(text: &str) -> Option { + // the one translated text `fmt_gap` writes ("1 Tag") + if text.trim() == crate::i18n::tr("1 day") { + return Some(86400.0); + } + let mut total = 0.0; + let mut seen = false; + let mut words = text.split_whitespace().peekable(); + while let Some(word) = words.next() { + // "90s" as well as "90 s" + let split = word.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(word.len()); + let (num, unit) = word.split_at(split); + let n: f64 = num.parse().ok().filter(|n: &f64| n.is_finite())?; + let unit = if unit.is_empty() { + match words.peek() { + Some(u) if u.parse::().is_err() => words.next().unwrap_or_default(), + _ => "s", + } + } else { + unit + }; + let scale = match unit.trim_end_matches('.').to_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => 1.0, + "m" | "min" | "mins" | "minute" | "minutes" => 60.0, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600.0, + "d" | "day" | "days" => 86400.0, + _ => return None, + }; + total += n * scale; + seen = true; + } + seen.then_some(total) +} + +/// The longest burst gap `photo.analyze` accepts: a day. +pub const MAX_BURST_GAP: f32 = 86_400.0; + +/// Keep what was typed in the Assisted Culling value fields within what `photo.analyze` accepts +/// (a gap of 0 s to a day, a similarity of 0 to 1), not within the sliders' drag range. +pub fn clamp_cull_burst(gap: &mut f32, similarity: &mut f32) { + *gap = if gap.is_finite() { gap.clamp(0.0, MAX_BURST_GAP) } else { 10.0 }; + *similarity = if similarity.is_finite() { similarity.clamp(0.0, 1.0) } else { 0.93 }; +} + +/// The `photo.analyze` parameters of the Assisted Culling dialog, its values as they are. +pub fn cull_params(reject_below: f32, pick_best: bool, burst_gap: f32, similarity: f32) -> serde_json::Value { + let mut p = json!({"pickBest": pick_best, "burstGap": burst_gap, "similarity": similarity}); + if reject_below > 0.0 { + p["rejectBelow"] = json!(reject_below); + } + p +} + /// `90` → `1 min 30 s`. pub fn fmt_gap(v: f64) -> String { let v = v.round() as u64; @@ -1584,7 +1753,9 @@ pub fn fmt_gap(v: f64) -> String { 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), 60..3600 => format!("{} min {} s", v / 60, v % 60), 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), - 3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60), + 3600..86400 if v.is_multiple_of(60) => format!("{} h {} min", v / 3600, v % 3600 / 60), + // the seconds too, so editing the text reads back the same gap + 3600..86400 => format!("{} h {} min {} s", v / 3600, v % 3600 / 60, v % 60), _ => crate::i18n::tr("1 day").into(), } } @@ -1664,10 +1835,8 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str let seg = &app.session.segmenter; if d.running { ui.add_space(4.0); - let frac = if d.total > 0 { d.done as f64 / d.total as f64 } else { 0.0 }; let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file); - let r = ui.add(egui::ProgressBar::new(frac as f32).text(text)); - crate::widgets::register(ui.ctx(), "progress:samDownload", r.rect); + crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui); let r = ui.button(crate::i18n::tr("Cancel Download")); crate::widgets::register(ui.ctx(), "button:samCancel", r.rect); if r.clicked() { @@ -1873,12 +2042,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::panels::denoise::install(app, info, *accepted), Dialog::FaceModel { path, info, accepted } => crate::panels::faces::install(app, path, info, *accepted), Dialog::AllMetadata { .. } | Dialog::SystemInfo { .. } | Dialog::WhatsNew => Ok(serde_json::Value::Null), - Dialog::Cull { reject_below, pick_best } => { - let mut p = json!({"pickBest": pick_best}); - if *reject_below > 0.0 { - p["rejectBelow"] = json!(reject_below); - } - app.run("photo.analyze", p) + Dialog::Cull { reject_below, pick_best, burst_gap, similarity } => { + app.ui.cull_burst = Some((*burst_gap, *similarity)); + app.run("photo.analyze", cull_params(*reject_below, *pick_best, *burst_gap, *similarity)) } Dialog::SmartRules { id, name, rules, parent } => { // refused before anything changes (not even the name) @@ -1932,6 +2098,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) } + d @ Dialog::LightroomDestination { .. } => crate::lightroom_destination::confirm(app, d), Dialog::SaveEffect { name, group, fields, amount, brush } => { crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) } @@ -2327,3 +2494,132 @@ fn only_shows(dlg: &Dialog) -> bool { | Dialog::ViewOptions ) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date()) } + +#[cfg(test)] +mod gap_tests { + use super::{fmt_gap, parse_gap}; + + #[test] + fn gap_text_reads_back_what_it_shows() { + for v in [0.0, 1.0, 12.0, 59.0, 60.0, 90.0, 600.0, 3599.0, 3600.0, 3601.0, 3661.0, 3900.0, 7325.0, 86340.0, 86399.0] { + assert_eq!(parse_gap(&fmt_gap(v)), Some(v), "{}", fmt_gap(v)); + } + assert_eq!(parse_gap("1 day"), Some(86400.0)); + assert_eq!(parse_gap("45"), Some(45.0)); + assert_eq!(parse_gap(" 45s "), Some(45.0)); + assert_eq!(parse_gap("2min 5s"), Some(125.0)); + for bad in ["", "abc", "5 parsecs", "NaN", "1 min x"] { + assert_eq!(parse_gap(bad), None, "{bad:?}"); + } + // a day reads back in the UI language too ("1 Tag") + crate::i18n::set_language(crate::i18n::Locale::De); + let shown = fmt_gap(86400.0); + let back = parse_gap(&shown); + crate::i18n::set_language(crate::i18n::Locale::En); + assert_eq!((shown.as_str(), back), ("1 Tag", Some(86400.0))); + } + + #[test] + fn gaps_past_the_slider_read_back_too() { + // the Assisted Culling slider drags to 10 min; its value field takes up to a day + for (v, text) in [(1800.0, "30 min"), (5400.0, "1 h 30 min"), (3601.0, "1 h 0 min 1 s"), (86400.0, "1 day")] { + assert_eq!(fmt_gap(v), text); + assert_eq!(parse_gap(text), Some(v), "{text}"); + } + assert_eq!(parse_gap("1800"), Some(1800.0)); + assert_eq!(parse_gap("0"), Some(0.0)); + } +} + +#[cfg(test)] +mod cull_dialog_tests { + use std::time::Duration; + + use serde_json::json; + + use super::{clamp_cull_burst, cull_params}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + #[test] + fn typed_values_are_kept_within_what_photo_analyze_accepts() { + for (gap, sim) in [(0.0, 0.0), (1800.0, 1.0), (86400.0, 0.5), (0.5, 0.995)] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), (gap, sim), "in range: kept as typed"); + } + for ((gap, sim), want) in [((-5.0, -0.1), (0.0, 0.0)), ((90000.0, 1.5), (86400.0, 1.0)), ((f32::NAN, f32::INFINITY), (10.0, 0.93))] { + let (mut g, mut s) = (gap, sim); + clamp_cull_burst(&mut g, &mut s); + assert_eq!((g, s), want); + } + assert_eq!(cull_params(0.0, true, 1800.0, 1.0), json!({"pickBest": true, "burstGap": 1800.0, "similarity": 1.0})); + assert_eq!(cull_params(20.0, false, 0.0, 0.7)["rejectBelow"], 20.0); + } + + /// Click the value field right of a slider registered as `id`, type `text`, Enter. + fn type_value(h: &mut Headless, id: &str, text: &str) { + let r = h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")); + let spacing = h.view.ctx.global_style().spacing.clone(); + let x = r.left() + spacing.slider_width + spacing.item_spacing.x + 6.0; + assert_eq!(h.request("ui.click", json!({"x": x, "y": r.center().y}), T)["ok"], true); + h.step(); + for key in ["a", "Backspace"] { + assert_eq!(h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T)["ok"], true); + } + assert_eq!(h.request("ui.text", json!({"text": text}), T)["ok"], true); + assert_eq!(h.request("ui.key", json!({"key": "Enter"}), T)["ok"], true); + h.step(); + h.step(); + } + + fn burst(h: &Headless) -> (f32, f32) { + match &h.app.ui.dialog { + Some(Dialog::Cull { burst_gap, similarity, .. }) => (*burst_gap, *similarity), + d => panic!("{d:?}"), + } + } + + #[test] + fn the_culling_value_fields_take_values_past_the_sliders() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.cull", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert_eq!(burst(&h), (10.0, 0.93)); + // half an hour and "only identical shots": past the sliders' 10 min and 0.99 + type_value(&mut h, "field:cullGap", "30 min"); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h), (1800.0, 1.0)); + // a day is as far as photo.analyze goes; more is held there + type_value(&mut h, "field:cullGap", "2 days"); + assert_eq!(burst(&h).0, 86400.0); + type_value(&mut h, "field:cullGap", "0"); + assert_eq!(burst(&h), (0.0, 1.0)); + type_value(&mut h, "field:cullGap", "1800"); + assert_eq!(burst(&h).0, 1800.0); + // a similarity between the slider's hundredths is kept to the thousandth + type_value(&mut h, "field:cullSimilar", "0.995"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "0.9951"); + assert_eq!(burst(&h).1, 0.995); + type_value(&mut h, "field:cullSimilar", "1.0"); + assert_eq!(burst(&h).1, 1.0); + // OK hands them to photo.analyze as typed (it refuses values out of its range) + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert_eq!(h.app.ui.cull_burst, Some((1800.0, 1.0))); + // and the next opening starts from them, not from the sliders' range + h.app.run("dialog.cull", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(burst(&h), (1800.0, 1.0)); + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 08f70bd02..1a770ebd8 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1552,7 +1552,8 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings // puck as it is at that moment. let grab_id = egui::Id::new(("wheelGrab", key)); let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); - let grab = match resp.interact_pointer_pos() { + let pointer = wheel_pointer(ui, &resp); + let grab = match pointer { Some(q) if resp.dragged() || resp.clicked() => { let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); Some(held.unwrap_or_else(|| { @@ -1591,7 +1592,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings let _ = app.run("develop.beginInteraction", json!({"label": crate::i18n::tr_format!("{label} Grading", label = label)})); } if (resp.dragged() || resp.clicked()) - && let Some(q) = resp.interact_pointer_pos() + && let Some(q) = pointer { let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); @@ -1599,6 +1600,13 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if resp.drag_stopped() || resp.clicked() { let _ = app.run("develop.endInteraction", json!({})); } + // released, or Esc (egui ends a drag on it) + if resp.drag_stopped() + && let Some(held) = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::new(WHEEL_CAPTURE))) + && !held.dropped + { + give_pointer_back(ui.ctx(), held.origin); + } if resp.double_clicked() { let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}})); } @@ -1702,6 +1710,99 @@ impl WheelGrab { } } +/// A wheel drag holds the pointer, as in Lightroom (issue #1899): it is hidden for the drag, the +/// puck follows the mouse's own travel, and on release the pointer is back where the button went +/// down, so a long (Alt) drag doesn't leave it far from the wheels. On macOS the pointer is locked +/// in place for the drag; elsewhere it moves unseen and is put back. The browser can't move it +/// (eframe's web host ignores the command), so there it only shows again where the drag ended. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct WheelCapture { + /// Where the button went down: the pointer goes back there. + origin: Pos2, + /// Where the pointer would be had it been free: where the drag began plus the travel since. + pos: Pos2, + /// Let go early (the window lost focus): the rest of this drag leaves the wheel alone. + dropped: bool, + /// The pass that last drew the dragged wheel. + pass: u64, +} + +/// One wheel is dragged at a time, so one capture for them all. +const WHEEL_CAPTURE: &str = "wheelCapture"; + +/// The mouse's travel this frame: the pointer's, or, while the pointer stands still (locked, or +/// against a screen edge), the raw motion the host reports. +fn wheel_travel(delta: egui::Vec2, motion: Option) -> egui::Vec2 { + if delta != egui::Vec2::ZERO { + return delta; + } + motion.filter(|m| m.x.is_finite() && m.y.is_finite()).unwrap_or(egui::Vec2::ZERO) +} + +/// Where the wheel `resp` reads the pointer this frame: where it clicked, or for a drag the held +/// pointer ([`WheelCapture`]), `None` once a lost focus let go of it (Esc ends the drag itself: +/// egui stops a drag on Esc). +fn wheel_pointer(ui: &egui::Ui, resp: &egui::Response) -> Option { + let q = resp.interact_pointer_pos()?; + if !resp.dragged() { + return Some(q); + } + let ctx = ui.ctx(); + let id = egui::Id::new(WHEEL_CAPTURE); + let held = ctx.data(|m| m.get_temp::(id)).filter(|_| !resp.drag_started()); + let mut cap = held.unwrap_or_else(|| { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked)); + } + WheelCapture { origin: ui.input(|i| i.pointer.press_origin()).unwrap_or(q), pos: q, ..Default::default() } + }); + cap.pass = ctx.cumulative_pass_nr(); + if !cap.dropped { + let (travel, unfocused) = ui.input(|i| { + // the event, not `focused`: a click that activates the window may land before its focus + let unfocused = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))); + (wheel_travel(i.pointer.delta(), i.pointer.motion()), unfocused) + }); + if !resp.drag_started() { + cap.pos += travel; + } + if unfocused { + give_pointer_back(ctx, cap.origin); + cap.dropped = true; + } else { + ctx.set_cursor_icon(egui::CursorIcon::None); + } + } + ctx.data_mut(|m| m.insert_temp(id, cap)); + (!cap.dropped).then_some(cap.pos) +} + +/// Unlock and show the pointer a wheel drag held, back at `origin`. +fn give_pointer_back(ctx: &egui::Context, origin: Pos2) { + if cfg!(target_os = "macos") { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)); + } + if cfg!(not(target_arch = "wasm32")) { + ctx.send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); + } +} + +/// Once a frame, after the panels: a held pointer stays hidden over whatever set its own cursor, +/// and is given back if no wheel drew this pass (the flyout closed or the view changed under the +/// drag), so it is never left locked or hidden. +pub(crate) fn wheel_pointer_frame_end(ctx: &egui::Context) { + let id = egui::Id::new(WHEEL_CAPTURE); + let Some(cap) = ctx.data(|m| m.get_temp::(id)) else { return }; + if cap.pass != ctx.cumulative_pass_nr() { + ctx.data_mut(|m| m.remove::(id)); + if !cap.dropped { + give_pointer_back(ctx, cap.origin); + } + } else if !cap.dropped { + ctx.set_cursor_icon(egui::CursorIcon::None); + } +} + /// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. /// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the /// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index f1eb97caf..902c0fd84 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -190,6 +190,9 @@ fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Val }; ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim)); + if st["checking"] == true { + crate::progress::spinner(ui, 12.0); + } let r = ui.label(text); register(ui.ctx(), format!("faces:device:{what}"), r.rect); if let Some(why) = why { @@ -312,10 +315,7 @@ fn scan_progress(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; } let text = format!("Scanning photos for faces: {left} left · {faces} faces so far"); - let bar = ui.add( - egui::ProgressBar::new((1.0 - left as f32 / peak as f32).clamp(0.0, 1.0)).desired_width(380.0).text(RichText::new(text).font(t.font(11.5))), - ); - register(ui.ctx(), "faces:scanProgress", bar.rect); + crate::progress::Progress::new(Some(1.0 - left as f32 / peak as f32)).label(text).widget("faces:scanProgress").show(ui); } fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, dl: Option<&Value>, can_run: bool) { @@ -361,17 +361,22 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, match (dl_state, dl) { (Some("running"), Some(d)) => { let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0)); - let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 }; let text = if total > 0 && got >= total { "Checking…".to_string() } else { format!("{} of {} from {host}", mb(Some(got)), mb(Some(total))) }; - let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5)))); - register(ui.ctx(), format!("faces:progress:{id}"), bar.rect); + crate::progress::Progress::new(crate::progress::fraction_of(got, total)) + .label(text) + .width(crate::progress::ROW_WIDTH.min(ui.available_width())) + .widget(format!("faces:progress:{id}")) + .show(ui); } (Some("done"), _) => { - ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + ui.horizontal(|ui| { + crate::progress::spinner(ui, 12.0); + ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); + }); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 8365a1ff8..8b524df11 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -130,22 +130,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .map(|(l, k, op)| (l.to_string(), json!({"ratingOp": k}), f.rating_op == op)) .collect(), ); - for i in 1..=5u8 { - let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); - crate::widgets::announce(&resp, format!("{i} ★")); - register(ui.ctx(), format!("filter:star{i}"), r); - let filled = i <= f.rating; - let c = if filled { - t.star - } else if resp.hovered() { - t.text_label - } else { - t.icon - }; - paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if filled { Icon::StarFilled } else { Icon::Star }, c); - if resp.clicked() { - filter(app, json!({"rating": if f.rating == i { 0 } else { i }})); - } + if let Some(r) = crate::rating::Rating::new("filter", f.rating).automation_ids("filter:star").show(ui) { + filter(app, json!({"rating": r})); } ui.add_space(14.0); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index fbe52cf12..883f47f8b 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -800,16 +800,18 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } } else { let ph = img_rect.shrink(if square { 20.0 } else { 0.0 }); - p.rect_filled(ph, 0.0, Color32::from_gray(38)); + p.rect_filled(ph, 0.0, t.skeleton); // not drawn yet (or unreadable): the selection is still visible if active { p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); } else if selected { - p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside); + // a light theme's skeleton is itself mid-grey: the ring takes the dim text colour + let ring = if t.skeleton.r() > 0x80 { t.text_dim } else { Color32::from_gray(170) }; + p.rect_stroke(ph, 0.0, Stroke::new(2.0, ring), StrokeKind::Outside); } if app.renderer.failure(Slot::Thumb(id)).is_some() { // unreadable / missing file - p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim); + p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text); } } // labels and badges @@ -857,10 +859,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; - for i in 0..photo.rating { - paint(p, Rect::from_min_size(pos2(x + i as f32 * 13.0, bar.center().y - 6.0), vec2(12.0, 12.0)), Icon::StarFilled, t.star); - } - x += photo.rating as f32 * 13.0 + 4.0; + x += crate::rating::paint_rating(p, pos2(x, bar.center().y), photo.rating, crate::rating::RatingSize::Medium, t.star) + 4.0; match photo.flag { Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 4f2cba91c..43190002a 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -202,6 +202,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId } // the tick box: does the selection have it? let state = ticks.tick(&r.path); + let inside = ticks.inside(&r.path); let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0)); let on = !selection.is_empty(); // (it senses drags too, so a press there that moves doesn't pick the keyword up) @@ -222,14 +223,27 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId } Tick::No => { ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside); + // the selection has a keyword inside this one: a dot in the empty box, so a folded + // keyword shows that it holds something applied (a shape of its own beside the tick + // and the dash, which are about the keyword itself) + if !inside.is_empty() { + let dot = Rect::from_center_size(boxr.center(), vec2(5.0, 5.0)); + ui.painter().circle_filled(dot.center(), 2.5, t.text); + register(ui.ctx(), format!("keywordInside:{}", r.path), dot); + } } } // (with nothing selected it does nothing, and says nothing) let tb = if on { - tb.on_hover_text(crate::i18n::tr(match state { + let does = crate::i18n::tr(match state { Tick::All => "Remove from Selected Photos", _ => "Add to Selected Photos", - })) + }); + // what is inside comes first: it is what the box shows + match inside_said(inside) { + Some(said) => tb.on_hover_text(format!("{said}\n{does}")), + None => tb.on_hover_text(does), + } } else { tb }; @@ -275,6 +289,17 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId crate::widgets::context_menu(&resp, |ui| menu(app, ui, &r.path, selection)); } +/// How many of the keywords inside one are named before the rest are counted. +const INSIDE_NAMED: usize = 4; + +/// "Inside: Rome, Venice": the applied keywords inside a keyword, the first few by name. +fn inside_said(inside: &[String]) -> Option { + let named = inside.iter().take(INSIDE_NAMED).map(String::as_str).collect::>().join(", "); + let more = inside.len().saturating_sub(INSIDE_NAMED); + let names = if more > 0 { format!("{named} {}", crate::i18n::tr_format!("+{n} more", n = more)) } else { named }; + (!inside.is_empty()).then(|| crate::i18n::tr_format!("Inside: {names}", names = names)) +} + /// A row while a keyword or photos are dragged: outlined under the pointer when the drop would /// do something (a keyword never goes inside itself), and a release there does it: nests the /// keyword inside this one, or gives this keyword to the photos. @@ -485,23 +510,51 @@ pub(crate) struct Ticks { photos: usize, /// Photos with it, by cleaned lower-case keyword. counts: std::collections::HashMap, + /// The applied keywords inside each keyword (by cleaned lower-case keyword): their own names, + /// in order, each once. What a folded keyword's box says of what it holds. + inside: std::collections::HashMap>, } impl Ticks { pub(crate) fn of(catalog: &Catalog, photos: &[PhotoId]) -> Ticks { let photos = super::keywording::distinct(catalog, photos); let mut counts: std::collections::HashMap = Default::default(); + // (each keyword as it was first written, to name it by) + let mut shown: std::collections::HashMap = Default::default(); let mut seen = std::collections::HashSet::new(); for p in &photos { seen.clear(); for k in &p.meta.keywords { - let key = lightcraft_catalog::keywords::clean(k).to_lowercase(); + let clean = lightcraft_catalog::keywords::clean(k); + let key = clean.to_lowercase(); if seen.insert(key.clone()) { - *counts.entry(key).or_default() += 1; + *counts.entry(key.clone()).or_default() += 1; + shown.entry(key).or_insert(clean); } } } - Ticks { photos: photos.len(), counts } + // every keyword above an applied one holds it: worked out here, once per selection, from the + // keywords the selection has (not per row and frame) + let mut inside: std::collections::HashMap> = Default::default(); + let mut applied: Vec<(&String, &String)> = shown.iter().collect(); + applied.sort(); + for (key, as_written) in applied { + let name = as_written.rsplit('|').next().unwrap_or(as_written); + for (at, _) in key.match_indices('|') { + let Some(above) = key.get(..at) else { continue }; + let names = inside.entry(above.to_string()).or_default(); + if !names.iter().any(|n| same(n, name)) { + names.push(name.to_string()); + } + } + } + Ticks { photos: photos.len(), counts, inside } + } + + /// The applied keywords inside `path` (their own names): what the selection has of the + /// keywords below it, at any depth. Empty for a keyword with nothing applied inside. + pub(crate) fn inside(&self, path: &str) -> &[String] { + self.inside.get(&lightcraft_catalog::keywords::clean(path).to_lowercase()).map_or(&[], Vec::as_slice) } /// The tick box of `path`: a photo counts when it has the keyword itself (any case); one with @@ -618,4 +671,31 @@ mod tests { let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); } + + /// Given photos that have keywords inside other keywords, when the list is asked what is + /// inside a keyword for the selection, then every keyword above an applied one names it (by + /// its own name, once, in order), whichever case it was written in and however deep; a keyword + /// with nothing applied inside, a keyword that is itself the applied one, and an empty + /// selection name nothing. + #[test] + fn a_keyword_names_the_applied_keywords_inside_it() { + let (c, ids) = library(&[&["Travel|Italy|Rome", "beach"], &["travel|italy|venice", "travel|Spain"], &["travel|italy|ROME"]]); + let all = Ticks::of(&c, &ids); + assert_eq!(all.inside("travel"), ["Italy|Rome", "Italy|venice", "Spain"].map(|k| k.rsplit('|').next().unwrap())); + assert_eq!(all.inside("Travel|Italy"), ["Rome", "venice"]); + assert_eq!(all.inside("travel|italy|rome"), [] as [&str; 0], "nothing is inside the applied keyword itself"); + assert_eq!(all.inside("travel|spain"), [] as [&str; 0]); + assert_eq!(all.inside("beach"), [] as [&str; 0]); + assert_eq!(all.inside("weddings"), [] as [&str; 0], "a keyword no photo has anything of"); + // the selection decides: the third photo alone has nothing of Spain or Venice + assert_eq!(Ticks::of(&c, &ids[2..]).inside("travel"), ["ROME"]); + assert_eq!(Ticks::of(&c, &[]).inside("travel"), [] as [&str; 0]); + // a keyword applied itself and with one inside: the tick is its own, and what is inside is still named + let (c, ids) = library(&[&["travel", "travel|Italy"]]); + let t = Ticks::of(&c, &ids); + assert_eq!((t.tick("travel"), t.inside("travel")), (Tick::All, &["Italy".to_string()][..])); + // a name that only starts like another is not inside it + let (c, ids) = library(&[&["travelling|Rome"]]); + assert_eq!(Ticks::of(&c, &ids).inside("travel"), [] as [&str; 0]); + } } diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 212e57d90..3081bc80c 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -2,14 +2,91 @@ //! blocking window says which library and why, and offers Try Again, Choose Another Library…, //! Continue Without Saving and Quit. Continuing shows a banner under the top bar for as long as the //! session is temporary. +//! +//! The same window lists the recent libraries to choose from when Settings ▸ General ▸ At launch +//! is *Ask me*, or when Alt/Option is held as the window first appears (issue #1290): Open, Open +//! Another…, New Library…, Remove from List and Quit, with the arrow keys, Return and Esc. +//! [`launch_plan`] decides at launch which of the two the desktop app starts with. +//! +//! Whether each recent library can be opened is checked on a worker ([`RecentChecks`]), never on +//! the UI thread: a library on a network share whose server is gone, or on a disk that has to +//! spin up, can take seconds to answer. Until its answer comes the list shows the row as +//! "Checking…" (it can still be opened: opening says why if it can't), and File ▸ Open Recent +//! Library keeps it enabled; the same answers grey out the menu's entries. + +use std::path::{Path, PathBuf}; use egui::{Align2, RichText, vec2}; use serde_json::json; use crate::LightcraftApp; +use crate::state::{AppSettings, LaunchLibrary}; use crate::theme::Tokens; use crate::widgets::register; +/// How long after the window appears holding Alt/Option still asks (egui time, seconds). The +/// modifier keys can't be read before the first frame, and macOS reports one held since before +/// launch only once it changes, so it is watched over the first frames. +pub const ALT_SECONDS: f64 = 2.0; + +/// What the desktop app opens at launch (issue #1290). +#[derive(Clone, Debug, PartialEq)] +pub enum Launch { + /// Open this library folder (`None`: there is nowhere to keep one; the problem window says + /// so). `alt_asks`: holding Alt/Option as the window appears offers the recent libraries. + Open { dir: Option, alt_asks: bool }, + /// Open nothing yet: the window lists the recent libraries ([`LibraryProblem::choose`]). + Ask, +} + +/// What to open at launch: `--library` (`flag`), else `$LIGHTCRAFT_LIBRARY` (`env`), else with +/// *Ask me* the list, else the library last opened, else `default` (the default location). +/// `settings` is `None` without a settings file; `memory` (`--memory`) never asks. +pub fn launch_plan(memory: bool, flag: Option, env: Option, settings: Option<&AppSettings>, default: Option) -> Launch { + if memory { + return Launch::Open { dir: None, alt_asks: false }; + } + if let Some(dir) = flag.or(env) { + return Launch::Open { dir: Some(dir), alt_asks: false }; + } + if settings.is_some_and(|s| s.at_launch == LaunchLibrary::Ask) { + return Launch::Ask; + } + let last = settings.map(|s| s.library_path.clone()).filter(|p| !p.is_empty()).map(PathBuf::from); + Launch::Open { dir: last.or(default), alt_asks: true } +} + +/// One library in the launch window's list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RecentLibrary { + pub path: String, + /// When it was last opened (ISO 8601), if known. + pub opened: Option, + /// Why it can't be opened (`None`: it can, or it is still being checked), as checked when + /// the window opened or when opening it failed. + pub unavailable: Option, + /// Its check hasn't answered yet ([`RecentChecks`]). + pub checking: bool, + /// Its check has gone on for longer than [`SLOW_AFTER`] (the drive may be asleep or offline). + pub slow: bool, +} + +/// The launch window's list of recent libraries (issue #1290). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Chooser { + /// Newest first, as File ▸ Open Recent Library lists them. + pub libraries: Vec, + /// The highlighted row (`None`: the list is empty). + pub selected: Option, + /// The person moved the highlight: answers arriving later don't move it again. + pub touched: bool, + /// Its rows' checks were started ([`logic`]). + pub asked: bool, + /// The library open behind the window (Alt/Option held at launch), by + /// [`lightcraft_engine::Session::library_generation`]: Esc keeps it. `None`: no library is open. + pub over: Option, +} + /// A library that failed to open (set by the host at launch). #[derive(Clone, Debug, Default, PartialEq)] pub struct LibraryProblem { @@ -24,25 +101,392 @@ pub struct LibraryProblem { /// Try Again reopens `path` as a library folder (`false` in the browser, whose storage isn't /// a folder: reloading the page tries again). pub can_retry: bool, + /// The window lists the recent libraries to choose from instead (issue #1290); `error` then + /// says why the last one tried didn't open. + pub chooser: Option, } impl LibraryProblem { + /// The window for a library at `path` that failed to open with `error`. pub fn new(path: impl Into, error: impl Into) -> LibraryProblem { LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() } } + /// The window listing the recent libraries, each checked now without writing anything + /// ([`unavailable`]): one that is gone, isn't a LightCraft library any more or can't be read + /// is greyed out with the reason. The last library opened is selected, or else the first one + /// that can be opened. + #[cfg(test)] + pub(crate) fn choose(settings: &AppSettings) -> LibraryProblem { + let mut p = LibraryProblem::choose_later(settings); + if let Some(c) = p.chooser.as_mut() { + for l in &mut c.libraries { + l.unavailable = unavailable(&l.path).map(|u| u.text()); + l.checking = false; + } + c.selected = initial_selection(&c.libraries, &settings.library_path); + c.asked = true; + } + p + } + + /// [`LibraryProblem::choose`] without looking at any folder: every row is "Checking…" until + /// [`logic`] has the answers from a worker ([`RecentChecks`]). The last library opened is + /// selected meanwhile (or the first row); once every answer is in, the selection follows the + /// same rule as [`LibraryProblem::choose`] unless the person moved it. + pub fn choose_later(settings: &AppSettings) -> LibraryProblem { + // as many as File ▸ Open Recent Library keeps (a hand-edited settings file may hold more) + let libraries: Vec = settings + .recent_libraries + .iter() + .take(crate::state::RECENT_LIBRARIES) + .map(|path| RecentLibrary { + path: path.clone(), + opened: settings.recent_opened.get(path).cloned(), + unavailable: None, + checking: true, + slow: false, + }) + .collect(); + let selected = libraries.iter().position(|l| l.path == settings.library_path).or((!libraries.is_empty()).then_some(0)); + LibraryProblem { chooser: Some(Chooser { libraries, selected, over: None, touched: false, asked: false }), ..Default::default() } + } + + /// The session promises to save nothing (the failed-library window or Continue Without + /// Saving): the host writes no settings meanwhile. The launch list is not such a session. + pub fn saves_nothing(&self) -> bool { + self.chooser.is_none() + } + /// `ui.inspect` → `libraryProblem`. pub fn to_json(&self) -> serde_json::Value { - json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len()}) + let chooser = self.chooser.as_ref().map(|c| { + let libraries: Vec = c + .libraries + .iter() + .map(|l| json!({"path": l.path, "lastOpened": l.opened, "available": l.unavailable.is_none(), "checking": l.checking, "reason": l.unavailable})) + .collect(); + json!({"libraries": libraries, "selected": c.selected, "libraryOpen": c.over.is_some()}) + }); + json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len(), "chooser": chooser}) } } +/// The row first selected: the last library opened if it can be opened, else the first one that +/// can, else the first row. +fn initial_selection(libraries: &[RecentLibrary], last: &str) -> Option { + let can = |l: &RecentLibrary| l.unavailable.is_none(); + libraries.iter().position(|l| l.path == last && can(l)).or_else(|| libraries.iter().position(can)).or((!libraries.is_empty()).then_some(0)) +} + +/// Why a recent library can't be opened, found on a worker and put into words on the UI thread +/// (the interface language is per thread). +#[derive(Clone, Debug, PartialEq)] +pub enum Unavailable { + /// Nothing at that path (moved, renamed, or on a disconnected drive). + NotFound, + /// A file, not a folder. + NotAFolder, + /// The folder can't be listed (the system's reason). + Unreadable(String), + /// The folder holds no LightCraft library any more. + NoLibrary, + /// The library's catalog file can't be opened (the system's reason). + CatalogUnreadable(String), + /// The check itself failed (its worker stopped, or couldn't start). + CheckFailed(String), + /// Looking at the path failed without saying it is gone (a parent folder it may not read, a + /// share that timed out; the system's reason). + LookupFailed(String), +} + +impl Unavailable { + /// The folder isn't a library that is there to open (gone, a file, or not a library any more): + /// what greys out File ▸ Open Recent Library and makes `app.recentLibraries` say `exists: false`. + /// A folder that can't be listed, an unreadable catalog or a check that failed doesn't say the + /// library is gone (the launch window still shows why, and offers Open only for one it checked). + pub fn is_missing(&self) -> bool { + // a folder that refuses to list is there (permissions, macOS privacy): opening it says why + matches!(self, Unavailable::NotFound | Unavailable::NotAFolder | Unavailable::NoLibrary) + } + + /// In the interface language. + pub fn text(&self) -> String { + match self { + Unavailable::NotFound => crate::i18n::tr("Not found: moved, renamed or on a disconnected drive").to_string(), + Unavailable::NotAFolder => crate::i18n::tr("This is a file, not a library folder").to_string(), + Unavailable::Unreadable(e) => format!("{} {e}", crate::i18n::tr("This folder can't be read:")), + Unavailable::NoLibrary => crate::i18n::tr("There is no LightCraft library in this folder any more").to_string(), + Unavailable::CatalogUnreadable(e) => format!("{} {e}", crate::i18n::tr("The library's catalog can't be read:")), + Unavailable::CheckFailed(e) => e.clone(), + Unavailable::LookupFailed(e) => format!("{} {e}", crate::i18n::tr("This library couldn't be checked:")), + } + } +} + +/// How old an answer must be to be asked again: a library not found, on its own (a drive plugged +/// back in); any other, when the window comes back to the front (a library moved away meanwhile). +/// Only "not found" goes on a timer: looking again at the others would wake every external drive +/// and network share in the list over and over. +pub const RECHECK_AFTER: std::time::Duration = std::time::Duration::from_secs(30); +/// How long a row stays "Checking…" before it says the drive isn't answering (it can still be +/// opened: opening says why if it can't). +pub const SLOW_AFTER: std::time::Duration = std::time::Duration::from_secs(5); + +/// The activity stack's row for a check (English: the stack translates it). +const CHECK_LABEL: &str = "Checking recent libraries"; + +/// One path's answer and when it came. +#[derive(Clone, Debug)] +struct Answer { + why: Option, + at: web_time::Instant, +} + +/// Answers to "can this recent library be opened?", each found on a worker thread of its own (a +/// library on a network share whose server is gone, or on a sleeping disk, can take seconds or +/// never answer; the others don't wait for it). +pub struct RecentChecks { + known: std::collections::HashMap, + /// Paths being checked, and since when. + pending: std::collections::HashMap, + /// How one path is checked ([`unavailable`]; tests put a slow one here). + check: std::sync::Arc Option + Send + Sync>, + /// [`RECHECK_AFTER`] (shorter in tests). + recheck_after: std::time::Duration, + /// Whether the window had the focus on the last frame: answers are refreshed when it comes back. + pub(crate) focused: bool, +} + +impl Default for RecentChecks { + /// Nothing known yet, checking with [`unavailable`]. + fn default() -> Self { + RecentChecks { + known: Default::default(), + pending: Default::default(), + check: std::sync::Arc::new(unavailable), + recheck_after: RECHECK_AFTER, + focused: false, + } + } +} + +impl RecentChecks { + /// Why `path` can't be opened, if a check said so (`None`: it can, or it isn't known yet). + pub fn reason(&self, path: &str) -> Option<&Unavailable> { + self.known.get(path).and_then(|a| a.why.as_ref()) + } + + /// Whether the library at `path` is there: `Some(false)` when a check found it missing + /// ([`Unavailable::is_missing`]), `Some(true)` when one found it, `None` when no check could tell. + pub fn exists(&self, path: &str) -> Option { + match self.known.get(path)?.why.as_ref() { + None => Some(true), + Some(why) if why.is_missing() => Some(false), + Some(_) => None, + } + } + + /// Whether a check of `path` has answered. + pub fn is_known(&self, path: &str) -> bool { + self.known.contains_key(path) + } + + /// Whether `path` is being checked. + pub fn is_checking(&self, path: &str) -> bool { + self.pending.contains_key(path) + } + + /// Since when `path` has been checked, if it is. + fn checking_since(&self, path: &str) -> Option { + self.pending.get(path).copied() + } + + /// A check is running. + pub fn is_running(&self) -> bool { + !self.pending.is_empty() + } + + /// Check paths with another function (tests). + pub fn with_check(check: impl Fn(&str) -> Option + Send + Sync + 'static) -> RecentChecks { + RecentChecks { check: std::sync::Arc::new(check), ..Default::default() } + } + + /// Ask again about answers older than `after` when the window comes back (tests). + #[cfg(test)] + pub fn recheck_after(mut self, after: std::time::Duration) -> RecentChecks { + self.recheck_after = after; + self + } + + /// `path` was just opened (or made): it can be opened. + pub fn opened(&mut self, path: &str) { + self.known.insert(path.to_string(), Answer { why: None, at: web_time::Instant::now() }); + } + + /// Opening `path` from the launch window failed. That says nothing about whether it is there + /// (another program may have it open): its answer is dropped, so it is checked again on the next + /// frame. The launch list keeps the open's error on that row for as long as it shows. + pub fn failed(&mut self, path: &str) { + self.known.remove(path); + } + + /// `path` left the list: forget its answer. + pub fn forget(&mut self, path: &str) { + self.known.remove(path); + } +} + +/// Check each of `paths` on a worker of its own (one that is being checked already is left to +/// finish). The activity stack lists each check while it runs (a quick one doesn't show: rows +/// appear after half a second); none can be stopped, as a stat that hangs can't be. +fn start_checks(app: &mut LightcraftApp, paths: Vec) { + for path in paths { + if app.recent_checks.pending.contains_key(&path) { + continue; + } + // asked again about an answer it has (not after a failed open, which dropped it and says why + // on its row) + let recheck = app.recent_checks.known.contains_key(&path); + let check = app.recent_checks.check.clone(); + let p = path.clone(); + let started = crate::tasks::spawn(app, CHECK_LABEL, Some("library"), move || check(&p), { + let path = path.clone(); + move |app, ctx, why| { + app.recent_checks.pending.remove(&path); + app.recent_checks.known.insert(path, Answer { why, at: web_time::Instant::now() }); + apply_answers(app); + ctx.request_repaint(); + } + }); + match started { + Ok(()) => { + // a row of the launch list waits for this answer again (a library not found looked for + // again, a window back to the front): without it, the row kept its old answer + if recheck + && let Some(l) = + app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()).and_then(|c| c.libraries.iter_mut().find(|l| l.path == path)) + { + l.checking = true; + l.slow = false; + l.unavailable = None; + } + app.recent_checks.pending.insert(path, web_time::Instant::now()); + } + // no worker thread: say so on the row, and don't try again every frame + Err(e) => { + log::warn!("{e}"); + app.recent_checks.known.insert(path, Answer { why: Some(Unavailable::CheckFailed(e)), at: web_time::Instant::now() }); + apply_answers(app); + } + } + } +} + +/// Checks whose worker stopped without answering (it panicked: `tasks::poll` already said so) +/// end as failed, so the row and the menu don't wait for them forever. +fn reap_checks(app: &mut LightcraftApp) { + // (every check has the same label: when none is listed any more, the ones still pending died) + if app.recent_checks.pending.is_empty() || app.tasks.is_running(CHECK_LABEL) { + return; + } + let dead: Vec = app.recent_checks.pending.keys().cloned().collect(); + for path in dead { + app.recent_checks.pending.remove(&path); + let why = Unavailable::CheckFailed(crate::i18n::tr("This library couldn't be checked").to_string()); + app.recent_checks.known.insert(path, Answer { why: Some(why), at: web_time::Instant::now() }); + } + apply_answers(app); +} + +/// Put the answers in into the launch list; once none is missing, select as +/// [`LibraryProblem::choose`] does unless the person moved the highlight. +fn apply_answers(app: &mut LightcraftApp) { + let last = app.ui.settings.library_path.clone(); + let checks = &app.recent_checks; + let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) else { return }; + let checking_before = c.libraries.iter().any(|l| l.checking); + for l in &mut c.libraries { + if l.checking && !checks.is_checking(&l.path) && checks.is_known(&l.path) { + l.unavailable = checks.reason(&l.path).map(Unavailable::text); + l.checking = false; + } + } + if checking_before && !c.libraries.iter().any(|l| l.checking) && !c.touched { + c.selected = initial_selection(&c.libraries, &last); + } +} + +/// Why the library at `path` can't be opened (`None`: as far as can be told without opening it). +/// +/// Read-only: the folder and its catalog file are looked at, never locked or written (the list +/// may name libraries on other drives, network shares or sleeping disks). A library another +/// program has open is therefore found when Open is pressed: the open fails and the window says +/// who has it. Runs on a worker: the reason is put into words on the UI thread. +fn unavailable(path: &str) -> Option { + let dir = Path::new(path); + // only a definite answer marks it missing: a lookup that fails otherwise (a parent folder it may + // not read, a share that timed out) is "couldn't check", and the library can still be opened + match std::fs::metadata(dir) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Some(Unavailable::NotFound), + Err(e) => return Some(Unavailable::LookupFailed(e.to_string())), + Ok(m) if !m.is_dir() => return Some(Unavailable::NotAFolder), + Ok(_) => {} + } + match std::fs::read_dir(dir) { + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Some(Unavailable::Unreadable(e.to_string())), + Err(e) => return Some(Unavailable::LookupFailed(e.to_string())), + Ok(_) => {} + } + // opening would make a new, empty library in it + if !lightcraft_engine::library::is_library(dir) { + return Some(Unavailable::NoLibrary); + } + // the catalog the open reads first: the snapshot, else the log alone (opened to read, not read) + let snapshot = dir.join(lightcraft_catalog::journal::SNAPSHOT); + let catalog = if snapshot.is_file() { snapshot } else { dir.join(lightcraft_catalog::journal::LOG) }; + if let Err(e) = std::fs::File::open(&catalog) { + return Some(Unavailable::CatalogUnreadable(e.to_string())); + } + None +} + /// What the user chose. +#[derive(Clone, Copy, Debug, PartialEq)] enum Choice { Retry, Choose, Temporary, Quit, + /// The launch list: open the selected library. + Open, + /// The launch list: forget the selected library. + Remove, + /// The launch list: make a library (New Library…). + New, + /// The launch list over a library already open: close the window and keep it. + Keep, +} + +/// Whether dropped photos wait for a library ([`hold_dropped`]): none is open and the window +/// asking for one is up (not dismissed by Continue Without Saving). +pub fn holds_drops(app: &LightcraftApp) -> bool { + app.session.library.is_none() && app.library_problem.as_ref().is_some_and(|p| !p.dismissed) +} + +/// Photos dropped on the window while no library is open (the launch list, or the window of a +/// library that failed to open): kept with the command line's files and imported once a library +/// opens, instead of going into the session the next library replaces. Gives the photos back when +/// a library is open or Continue Without Saving was chosen. +pub fn hold_dropped(app: &mut LightcraftApp, ctx: &egui::Context, photos: Vec) -> Option> { + // called every frame, with nothing dropped most of the time: only a real drop is held (and toasted) + if photos.is_empty() || !holds_drops(app) { + return Some(photos); + } + let problem = app.library_problem.as_mut()?; + problem.pending_import.extend(photos); + app.toast(ctx, crate::i18n::tr("The photos are imported once a library is open")); + None } /// Open `path` (`None`: ask for a folder) through `app.openLibrary`; on success the problem is @@ -64,7 +508,21 @@ fn open(app: &mut LightcraftApp, path: Option) { } } Err(e) => { + // a failed switch reopens the library that was open behind the launch list + let generation = app.session.library.as_ref().map(|_| app.session.library_generation()); + if let Some(path) = path.as_deref() { + app.recent_checks.failed(path); + } if let Some(p) = app.library_problem.as_mut() { + if let Some(c) = p.chooser.as_mut() { + if c.over.is_some() { + c.over = generation; + } + if let Some(l) = c.libraries.iter_mut().find(|l| path.as_deref() == Some(l.path.as_str())) { + l.unavailable = Some(e.clone()); + l.checking = false; + } + } if let Some(path) = path { p.path = path; } @@ -75,11 +533,154 @@ fn open(app: &mut LightcraftApp, path: Option) { } } -/// Clear the problem once a library is open (e.g. through Settings → Open Library…). -pub fn logic(app: &mut LightcraftApp) { - if app.library_problem.is_some() && app.session.library.is_some() { - app.library_problem = None; +/// Clear the problem once a library is open (e.g. through Settings → Open Library…) — another +/// one than the launch list was opened over; offer the list when Alt/Option is held as the window +/// appears; and the list's keys. +pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { + if let Some(p) = &app.library_problem + && app.session.library.is_some() + && p.chooser.as_ref().and_then(|c| c.over) != Some(app.session.library_generation()) + { + // files from the command line go into the library opened another way (New Library…, + // File ▸ Open Library…), as through the window's own buttons + let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default(); + if !files.is_empty() + && let Err(e) = crate::import::start_paths(app, files) + { + log::warn!("import: {e}"); + } } + alt_at_launch(app, ctx); + reap_checks(app); + // the launch list asks for fresh answers once + let listed: Option> = + app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()).and_then(|c| { + (!std::mem::replace(&mut c.asked, true)).then(|| c.libraries.iter().filter(|l| l.checking).map(|l| l.path.clone()).collect()) + }); + if let Some(listed) = listed { + for path in &listed { + if !app.recent_checks.is_checking(path) { + app.recent_checks.forget(path); + } + } + start_checks(app, listed); + apply_answers(app); + } + // File ▸ Open Recent Library: paths never checked; libraries not found, every RECHECK_AFTER (a + // drive plugged back in; looking at a path that isn't there wakes nothing); the others when the + // window comes back to the front and their answer is that old. The list as shown (at most + // RECENT_LIBRARIES). + let focused = ctx.input(|i| i.focused); + let regained = focused && !std::mem::replace(&mut app.recent_checks.focused, focused); + let after = app.recent_checks.recheck_after; + let stale: Vec = app + .ui + .settings + .recent_libraries + .iter() + .take(crate::state::RECENT_LIBRARIES) + .filter(|p| { + !app.recent_checks.is_checking(p) + && app + .recent_checks + .known + .get(*p) + .is_none_or(|a| (regained || matches!(a.why, Some(Unavailable::NotFound))) && a.at.elapsed() > after) + }) + .cloned() + .collect(); + start_checks(app, stale); + if app.recent_checks.is_running() { + // rows still checking say so once it takes long + let checks = &app.recent_checks; + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) { + for l in c.libraries.iter_mut().filter(|l| l.checking) { + l.slow = checks.checking_since(&l.path).is_some_and(|t| t.elapsed() > SLOW_AFTER); + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + chooser_keys(app, ctx); +} + +/// Alt/Option held during the first [`ALT_SECONDS`] after the window appears opens the launch list +/// once, over the library that opened (opening another one closes it). Not while files from the +/// command line are importing into that library. +fn alt_at_launch(app: &mut LightcraftApp, ctx: &egui::Context) { + if !app.alt_asks_for_library { + return; + } + let (alt, time) = ctx.input(|i| (i.modifiers.alt, i.time)); + if time > ALT_SECONDS || app.library_problem.is_some() { + app.alt_asks_for_library = false; + return; + } + if !alt { + return; + } + app.alt_asks_for_library = false; + if crate::panels::settings::can_switch_library(app).is_err() { + return; + } + let mut p = LibraryProblem::choose_later(&app.ui.settings); + if let Some(c) = p.chooser.as_mut() { + c.over = app.session.library.as_ref().map(|_| app.session.library_generation()); + } + app.library_problem = Some(p); + ctx.request_repaint(); +} + +/// The launch list on screen (no dialog over it, e.g. New Library's name). +fn chooser_shown(app: &LightcraftApp) -> Option<&Chooser> { + let p = app.library_problem.as_ref().filter(|p| !p.dismissed)?; + p.chooser.as_ref().filter(|_| app.ui.dialog.is_none()) +} + +/// ↑ / ↓ move the selection, Return opens it (unless a button has the keyboard focus: it acts +/// itself), Esc quits — or, over a library already open, keeps it. Taken before the shortcuts. +fn chooser_keys(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(c) = chooser_shown(app) else { return }; + let (n, selected, over) = (c.libraries.len(), c.selected, c.over.is_some()); + let focused = ctx.memory(|m| m.focused().is_some()); + use egui::{Key, Modifiers}; + let (up, down, enter, esc) = ctx.input_mut(|i| { + let up = i.consume_key(Modifiers::NONE, Key::ArrowUp); + let down = i.consume_key(Modifiers::NONE, Key::ArrowDown); + let enter = !focused && i.consume_key(Modifiers::NONE, Key::Enter); + (up, down, enter, i.consume_key(Modifiers::NONE, Key::Escape)) + }); + if (up || down) && n > 0 { + let at = match selected { + Some(s) if up => s.saturating_sub(1), + Some(s) => (s + 1).min(n - 1), + None => 0, + }; + select(app, at); + } + if enter { + act(app, Choice::Open); + } else if esc { + act(app, if over { Choice::Keep } else { Choice::Quit }); + } +} + +/// Highlight row `at` of the launch list. +fn select(app: &mut LightcraftApp, at: usize) { + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) + && at < c.libraries.len() + { + c.selected = Some(at); + c.touched = true; + } +} + +/// Dim everything behind the window; it swallows clicks, so nothing there can be used meanwhile. +fn dim(ctx: &egui::Context) { + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { + ui.allocate_rect(screen, egui::Sense::click_and_drag()); + ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); + }); } /// The blocking window (until a choice is made). @@ -88,13 +689,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if problem.dismissed { return; } + if let Some(chooser) = &problem.chooser { + // a dialog it opened (New Library's name) goes first; the list comes back after it + if app.ui.dialog.is_none() { + show_chooser(app, ctx, &problem, chooser); + } + return; + } let t = Tokens::get(ctx); - let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { - // swallows clicks: nothing behind the window can be used meanwhile - ui.allocate_rect(screen, egui::Sense::click_and_drag()); - ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); - }); + dim(ctx); let mut choice = None; let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) @@ -139,10 +742,50 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { button(ui, "libraryQuit", "Quit", true, Choice::Quit); }); }); + if let Some(c) = choice { + act(app, c); + } +} + +/// Carry out `choice` (a button or key of either window). +fn act(app: &mut LightcraftApp, choice: Choice) { + let Some(problem) = app.library_problem.clone() else { return }; + let chosen = problem.chooser.as_ref().and_then(|c| c.selected.and_then(|i| c.libraries.get(i)).map(|l| (l.clone(), c.over))); match choice { - Some(Choice::Retry) => open(app, Some(problem.path)), - Some(Choice::Choose) => open(app, None), - Some(Choice::Temporary) => { + Choice::Retry => open(app, Some(problem.path)), + Choice::Choose => open(app, None), + Choice::Open => { + let Some((library, over)) = chosen.filter(|(l, _)| l.unavailable.is_none()) else { return }; + // the library already open behind the list: nothing to switch + let open_now = app.session.library.as_ref().filter(|l| l.on_disk).is_some_and(|l| l.dir == Path::new(&library.path)); + if over.is_some() && open_now { + app.library_problem = None; + } else { + open(app, Some(library.path)); + } + } + Choice::Remove => { + let Some((library, _)) = chosen else { return }; + crate::panels::settings::forget_library(&mut app.ui.settings, &library.path); + app.recent_checks.forget(&library.path); + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) { + c.libraries.retain(|l| l.path != library.path); + c.selected = c.selected.map(|s| s.min(c.libraries.len().saturating_sub(1))).filter(|_| !c.libraries.is_empty()); + } + } + Choice::New => { + // asks for the name, then the place; the list shows again if that's cancelled + match app.run("app.newLibrary", json!({})) { + Ok(_) => {} + Err(e) => { + if let Some(p) = app.library_problem.as_mut() { + p.error = e; + } + } + } + } + Choice::Keep => app.library_problem = None, + Choice::Temporary => { if let Some(p) = app.library_problem.as_mut() { p.dismissed = true; } @@ -154,11 +797,137 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { log::warn!("import: {e}"); } } - Some(Choice::Quit) => app.ui.quit = true, - None => {} + Choice::Quit => app.ui.quit = true, } } +/// The launch list (issue #1290): the recent libraries, greyed out with the reason when they +/// can't be opened, and Open / Open Another… / New Library… / Remove from List / Quit. +fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryProblem, chooser: &Chooser) { + let t = Tokens::get(ctx); + dim(ctx); + let mut choice = None; + let mut clicked_row = None; + let can_pick = app.services.pick_folder.is_some(); + let open_now = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); + egui::Window::new(crate::i18n::tr("Open a Library")) + .id(egui::Id::new("library-chooser")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .frame(frame) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .default_width(520.0) + .show(ctx, |ui| { + // a fixed width: a wrapped label in an auto-sized, centred window would move it every frame + ui.set_width(500.0); + ui.spacing_mut().item_spacing.y = 8.0; + ui.label(RichText::new(crate::i18n::tr("Choose the library to open.")).color(t.text_label)); + if chooser.libraries.is_empty() { + let r = ui.label(RichText::new(crate::i18n::tr("No recent libraries. Open one from a folder, or make a new one.")).color(t.text_dim)); + register(ui.ctx(), "indicator:noRecentLibraries", r.rect); + } + egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for (i, library) in chooser.libraries.iter().enumerate() { + let r = recent_row(ui, &t, i, library, chooser.selected == Some(i), open_now.as_deref() == Some(Path::new(&library.path))); + if r.double_clicked() { + clicked_row = Some((i, true)); + } else if r.clicked() { + clicked_row = Some((i, false)); + } + } + }); + if !problem.error.is_empty() { + ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); + } + let selected = chooser.selected.and_then(|i| chooser.libraries.get(i)); + let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0))); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.clicked() { + choice = Some(c); + } + }; + ui.horizontal(|ui| { + button(ui, "libraryForget", "Remove from List", selected.is_some(), Choice::Remove); + ui.label(RichText::new(crate::i18n::tr("The library itself stays where it is.")).size(11.0).color(t.text_dim)); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + button(ui, "libraryOpen", "Open", selected.is_some_and(|l| l.unavailable.is_none()), Choice::Open); + button(ui, "libraryOpenAnother", "Open Another…", can_pick, Choice::Choose); + button(ui, "libraryNew", "New Library…", can_pick, Choice::New); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + button(ui, "libraryQuit", "Quit", true, Choice::Quit); + }); + }); + }); + if let Some((i, open)) = clicked_row { + select(app, i); + if open { + act(app, Choice::Open); + } + } + if let Some(c) = choice { + act(app, c); + } +} + +/// One row of the launch list: the library's name and when it was last opened, the folder it is +/// in, and why it can't be opened (then greyed out). Registered as `libraryRecent:`. +fn recent_row(ui: &mut egui::Ui, t: &Tokens, i: usize, library: &RecentLibrary, selected: bool, open: bool) -> egui::Response { + let dir = Path::new(&library.path); + let name = crate::panels::settings::library_name(dir); + let parent = dir.parent().map(|p| p.display().to_string()).unwrap_or_default(); + let ok = library.unavailable.is_none(); + let (main, dim) = if ok { (t.text, t.text_dim) } else { (t.text_disabled, t.text_disabled) }; + let background = ui.painter().add(egui::Shape::Noop); + let inner = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 1.0; + ui.horizontal(|ui| { + ui.add(egui::Label::new(RichText::new(name).font(t.semibold(12.5)).color(main)).truncate().selectable(false)); + let when = if open { + Some(crate::i18n::tr("Open now").to_string()) + } else { + library + .opened + .as_deref() + .and_then(|iso| iso.get(..10)) + .map(|day| format!("{} {}", crate::i18n::tr("Last opened"), crate::i18n::date_group_label(day, false))) + }; + if let Some(when) = when { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(when).size(11.0).color(dim)).selectable(false)); + }); + } + }); + ui.add(egui::Label::new(RichText::new(parent).size(11.0).color(dim)).truncate().selectable(false)); + if let Some(why) = &library.unavailable { + ui.add(egui::Label::new(RichText::new(why).size(11.0).color(t.caution)).wrap().selectable(false)); + } else if library.checking { + let text = + if library.slow { crate::i18n::tr("Not answering yet: the drive may be asleep or offline") } else { crate::i18n::tr("Checking…") }; + ui.add(egui::Label::new(RichText::new(text).size(11.0).color(dim)).wrap().selectable(false)); + } + }); + let r = ui.interact(inner.response.rect, egui::Id::new(("library-recent", i)), egui::Sense::click()); + register(ui.ctx(), format!("libraryRecent:{i}"), r.rect); + let fill = if selected { + Some(t.tool_active) + } else if r.hovered() { + Some(t.hover) + } else { + None + }; + if let Some(fill) = fill { + ui.painter().set(background, egui::Shape::rect_filled(r.rect, 4.0, fill)); + } + r +} + /// The banner under the top bar while the session is temporary (Continue Without Saving). pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !app.library_problem.as_ref().is_some_and(|p| p.dismissed) { diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 6062d241c..ef17bfba0 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -237,7 +237,12 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option let elided = g.elided; ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); if fav { - ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + crate::rating::paint_star( + ui.painter(), + egui::Rect::from_center_size(rect.right_center() - vec2(10.0, 0.0), vec2(11.0, 11.0)), + true, + t.text_dim, + ); } if moved && st.highlight == Some(i) { resp.scroll_to_me(None); @@ -522,10 +527,7 @@ pub fn manage_body( for e in list { let sel = selected.contains(&e.name); ui.horizontal(|ui| { - let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); - if ui.is_rect_visible(star.rect) { - register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); - } + let star = crate::rating::favorite_button(ui, format!("effectStar:{}", e.name), e.favorite, crate::i18n::tr("Favorite")); if star.clicked() { toggle_star = Some((e.name.clone(), !e.favorite)); } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 4fb31be80..675e5da6f 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -169,7 +169,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }; if let Some(status) = status { ui.add_space(4.0); - ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap()); + ui.horizontal_top(|ui| { + crate::progress::spinner(ui, 14.0); + ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap()); + }); ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); } // the model download, while its dialog is closed @@ -177,11 +180,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if download.running && app.ui.dialog.is_none() { ui.add_space(4.0); ui.horizontal(|ui| { - let pct = if download.total > 0 { download.done as f64 / download.total as f64 } else { 0.0 }; - let r = ui.add( - egui::ProgressBar::new(pct as f32).desired_width(ui.available_width() - 70.0).text(format!("SAM 3: {} %", (pct * 100.0).floor())), - ); - register(ui.ctx(), "maskSamProgress", r.rect); + // the bar takes what the Details button leaves + let details = ui.spacing().interact_size.x + 30.0; + sam_download_bar(ui, download.done, download.total, (ui.available_width() - details).max(96.0)); if text_button(ui, "maskSamDetails", crate::i18n::tr("Details"), false).clicked() { app.offer_sam_download(None); } @@ -760,6 +761,28 @@ pub(crate) fn start_describe(app: &mut LightcraftApp, op: &str) { app.ui.describe = Some((op.to_string(), String::new())); } +/// The SAM model's download, `width` wide. The label is a percentage once the size is known and +/// just the model's name before, so the bar keeps its height and its accessible name. +pub(crate) fn sam_download_bar(ui: &mut egui::Ui, done: u64, total: u64, width: f32) -> egui::Response { + let fraction = crate::progress::fraction_of(done, total); + let label = match fraction { + Some(f) => format!("SAM 3: {} %", crate::progress::percent(f)), + None => "SAM 3".to_string(), + }; + crate::progress::Progress::new(fraction).label(label).width(width).widget("maskSamProgress").show(ui) +} + +/// Whether the segmenter has work (loading, analyzing, a request, a detail pass running or waiting +/// to start): nothing new starts then. +fn segmenter_working(analyzing: bool, busy: bool, detail_busy: bool, detail_due: bool) -> bool { + analyzing || busy || detail_busy || detail_due +} + +/// Enter in the Describe field is the Select button: it starts nothing while the segmenter works. +fn enter_submits(working: bool, lost_focus: bool, enter_pressed: bool) -> bool { + !working && lost_focus && enter_pressed +} + /// The Describe field: type what to select ("sky", "the red car") and press Return. fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { let Some((op, mut text)) = app.ui.describe.clone() else { return }; @@ -777,7 +800,9 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { let mut submit = false; // the (translated) Select button is placed first, at the right; the field takes what is left ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() { + let seg = &app.session.segmenter; + let working = segmenter_working(seg.analyzing(), seg.busy(), seg.detail_busy(), app.ui.detail_due.is_some()); + if crate::button::Button::new("Select").small().busy(working).widget("button:maskDescribeGo").show(ui).clicked() { submit = true; } let r = ui.add( @@ -789,7 +814,8 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) { if !r.has_focus() && !r.lost_focus() && text.is_empty() { r.request_focus(); } - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // Enter is the Select button: nothing starts while the segmenter is working + if enter_submits(working, r.lost_focus(), ui.input(|i| i.key_pressed(egui::Key::Enter))) { submit = true; } }); @@ -897,6 +923,28 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn a_detail_pass_counts_as_the_segmenter_working() { + assert!(segmenter_working(false, false, true, false)); + assert!(segmenter_working(true, false, false, false)); + assert!(segmenter_working(false, true, false, false)); + assert!(!segmenter_working(false, false, false, false)); + } + + #[test] + fn a_pending_detail_pass_counts_as_the_segmenter_working() { + // a new prompt finishing before it starts would replace its target, and that mask never refines + assert!(segmenter_working(false, false, false, true)); + } + + #[test] + fn enter_in_the_describe_field_starts_nothing_while_the_segmenter_works() { + assert!(enter_submits(false, true, true)); + assert!(!enter_submits(true, true, true)); + assert!(!enter_submits(false, false, true)); + assert!(!enter_submits(false, true, false)); + } + /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// room by dropping a column rather than drawing it past its tile. #[test] diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2fcb36663..7c432e50d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod presets; pub mod profiles; pub mod right; pub mod rules_editor; +pub mod rules_move; pub mod second; pub mod settings; pub mod slide_layout; diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index a8cf21424..b4b753d68 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -82,6 +82,8 @@ impl AppliedByPhoto { } } +/// The Presets panel: the Favorites section of starred presets on top (issue #1999), then the +/// groups of built-in and user presets, each row applying its preset on click. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| { @@ -166,12 +168,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // imported whole is 10 000+ presets, and building every row each frame took seconds. let visible = ui.clip_rect().expand(row_h); for grp in groups.iter() { - let (g, builtin) = (grp.name.clone(), grp.builtin); - let open_id = egui::Id::new(("preset-group", &g)); + let (g, builtin, favorites) = (grp.name.clone(), grp.builtin, grp.favorites); + // the Favorites section opens and closes apart from a user group named "Favorites" + let open_id = egui::Id::new(("preset-group", favorites, &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); crate::widgets::announce(&resp, g.to_string()); - register(ui.ctx(), format!("presetGroup:{g}"), r); + // outside the `presetGroup:` ids, which a user group of any name can take + register(ui.ctx(), if favorites { "presetFavorites".to_string() } else { format!("presetGroup:{g}") }, r); paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), @@ -188,11 +192,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Export Group…")).clicked() { - let _ = app.run("file.exportPresets", json!({"group": g})); - } - }); + if !favorites { + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Export Group…")).clicked() { + let _ = app.run("file.exportPresets", json!({"group": g})); + } + }); + } if !open { continue; } @@ -205,7 +211,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); crate::widgets::announce(&resp, name.clone()); - register(ui.ctx(), format!("preset:{pid}"), r); + // a starred preset is listed twice: in Favorites and in its group + register(ui.ctx(), if favorites { format!("presetFavorite:{pid}") } else { format!("preset:{pid}") }, r); let is_last = marked.as_deref() == Some(pid.as_str()); if is_last { register(ui.ctx(), "presetApplied", r); @@ -227,7 +234,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), Icon::StarFilled, t.star); + crate::rating::paint_star( + ui.painter(), + Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), + true, + t.star, + ); } // resting on a preset previews it in the loupe (no history entry) if resp.hovered() @@ -321,6 +333,8 @@ struct Group { builtin: bool, /// Holds a user preset (a Move to Group target). user: bool, + /// The Favorites section on top (issue #1999): the starred presets, each also in its own group. + favorites: bool, items: Vec, } @@ -335,12 +349,21 @@ fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { } let mut by_name: BTreeMap<&str, Group> = BTreeMap::new(); for (i, p) in presets.iter().enumerate() { - let g = by_name.entry(&p.group).or_insert_with(|| Group { name: p.group.clone(), builtin: false, user: false, items: Vec::new() }); + let g = by_name.entry(&p.group).or_insert_with(|| Group { + name: p.group.clone(), + builtin: false, + user: false, + favorites: false, + items: Vec::new(), + }); g.builtin |= p.builtin; g.user |= !p.builtin; g.items.push(i); } - let groups = Arc::new(by_name.into_values().collect::>()); + // starred presets first, in their own section, as Profiles does + let starred: Vec = presets.iter().enumerate().filter(|(_, p)| p.favorite).map(|(i, _)| i).collect(); + let favorites = (!starred.is_empty()).then(|| Group { name: "Favorites".into(), builtin: true, user: false, favorites: true, items: starred }); + let groups = Arc::new(favorites.into_iter().chain(by_name.into_values()).collect::>()); ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone()))); groups } diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 8a4b08345..000ebdbcb 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -182,7 +182,7 @@ fn cell_ui( let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); if fav || resp.hovered() { painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); - paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); + crate::rating::paint_star(painter, star.shrink(4.0), fav, if fav || over_star { t.star } else { Color32::WHITE }); } if resp.hovered() && !applied && !over_star { app.hover_preview = diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79cd28712..a29534be3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -642,7 +642,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); } ui.add_space(8.0); - if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { + if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { let mut params = json!({"rating": r}); crate::panels::compare::target_active(app, &mut params); let _ = app.run("photo.rate", params); @@ -866,7 +866,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { } }); } - // where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the + // where the camera focused, and how (View ▸ Focus Point draws it): only when known; the // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits if let Some(text) = focus_text(m.focus.as_ref()) { ui.add_space(3.0); diff --git a/crates/ui-egui/src/panels/rules_move.rs b/crates/ui-egui/src/panels/rules_move.rs new file mode 100644 index 000000000..de5ccc2ef --- /dev/null +++ b/crates/ui-egui/src/panels/rules_move.rs @@ -0,0 +1,413 @@ +//! The move engine behind reordering rules and groups in the smart-album editor +//! ([`super::rules_editor`]): pure functions on a [`RuleSet`], and nothing else. A row is a path of +//! positions from the top list (`[1, 0]`: the first rule of the second rule's group); a place to +//! drop is a path too: the group's path and the position among its rules, counted before the row +//! leaves its old place. +//! +//! [`judge`], [`move_node`] and [`key_dest`] never trust a path: one that leads nowhere changes +//! nothing. A group left empty by a move goes away, and groups nest at most [`MAX_GROUP_DEPTH`] deep. + +use lightcraft_catalog::{Rule, RuleSet}; + +/// Groups nest at most this deep: the editor offers a group only in a list whose depth is below it +/// (the top list is depth 0). +pub const MAX_GROUP_DEPTH: usize = 3; + +/// Where the arrow keys send a row (↑ ↓ ← →). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dir { + Up, + Down, + /// ← : out of its group. + Out, + /// → : into the group next to it. + In, +} + +/// Why a move is refused (shown, not just ignored). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// A group dropped inside itself or something in it. + IntoItself, + /// A group dropped where its rules would nest deeper than [`MAX_GROUP_DEPTH`]. + TooDeep, +} + +/// What a move of one row to a place would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verdict { + Moves, + /// Where it already is, or a place or row that isn't there. + Nothing, + Refused(Refusal), +} + +/// The group (or the top list) at `path`. +fn list_at<'a>(rs: &'a RuleSet, path: &[usize]) -> Option<&'a RuleSet> { + let mut list = rs; + for &i in path { + match list.rules.get(i)? { + Rule::Group { group } => list = group, + Rule::Field { .. } => return None, + } + } + Some(list) +} + +fn list_at_mut<'a>(rs: &'a mut RuleSet, path: &[usize]) -> Option<&'a mut RuleSet> { + let mut list = rs; + for &i in path { + match list.rules.get_mut(i)? { + Rule::Group { group } => list = group, + Rule::Field { .. } => return None, + } + } + Some(list) +} + +/// The rule or group at `path`. +fn node_at<'a>(rs: &'a RuleSet, path: &[usize]) -> Option<&'a Rule> { + let (last, parent) = path.split_last()?; + list_at(rs, parent)?.rules.get(*last) +} + +/// How many lists a rule brings: none for a field rule, one more than its deepest group for a group. +fn height(rule: &Rule) -> usize { + match rule { + Rule::Field { .. } => 0, + Rule::Group { group } => 1 + group.rules.iter().map(height).max().unwrap_or(0), + } +} + +fn remove_at(rs: &mut RuleSet, path: &[usize]) -> Option { + let (last, parent) = path.split_last()?; + let list = list_at_mut(rs, parent)?; + (*last < list.rules.len()).then(|| list.rules.remove(*last)) +} + +fn insert_at(rs: &mut RuleSet, path: &[usize], rule: Rule) -> Option<()> { + let (last, parent) = path.split_last()?; + let list = list_at_mut(rs, parent)?; + if *last > list.rules.len() { + return None; + } + list.rules.insert(*last, rule); + Some(()) +} + +/// `path` as it reads once the row at `removed` is gone: a later sibling's position drops by one. +/// `false` when `path` runs through the removed row itself. +fn shift_after_removal(path: &mut [usize], removed: &[usize]) -> bool { + let Some((last, parent)) = removed.split_last() else { return true }; + if path.len() <= parent.len() || !path.starts_with(parent) { + return true; + } + let through = path.len() > removed.len(); + let Some(at) = path.get_mut(parent.len()) else { return true }; + if *at > *last { + *at -= 1; + true + } else { + *at != *last || !through + } +} + +/// What moving the row at `from` to the place `dest` would do. +pub fn judge(rs: &RuleSet, from: &[usize], dest: &[usize]) -> Verdict { + let (Some((from_last, from_parent)), Some((dest_last, dest_parent))) = (from.split_last(), dest.split_last()) else { + return Verdict::Nothing; + }; + let Some(node) = node_at(rs, from) else { return Verdict::Nothing }; + if matches!(node, Rule::Group { .. }) && dest_parent.starts_with(from) { + return Verdict::Refused(Refusal::IntoItself); + } + let Some(list) = list_at(rs, dest_parent) else { return Verdict::Nothing }; + if *dest_last > list.rules.len() { + return Verdict::Nothing; + } + // before itself, or after itself: the same place + if dest_parent == from_parent && (dest_last == from_last || *dest_last == from_last.saturating_add(1)) { + return Verdict::Nothing; + } + // a group may not end up deeper than the editor offers, but one that is already deeper (rules + // written by an agent) can still be moved about at its own depth or higher + let h = height(node); + let (now, then) = (from_parent.len() + h, dest_parent.len() + h); + if h > 0 && then > MAX_GROUP_DEPTH && then > now { + return Verdict::Refused(Refusal::TooDeep); + } + Verdict::Moves +} + +/// Moves the row at `from` to the place `dest` (see the module doc), if [`judge`] allows it, and +/// returns where the row is now. Groups it leaves empty go away. All or nothing: `rs` is only +/// replaced by a finished result. +pub fn move_node(rs: &mut RuleSet, from: &[usize], dest: &[usize]) -> Option> { + if judge(rs, from, dest) != Verdict::Moves { + return None; + } + let mut next = rs.clone(); + let node = remove_at(&mut next, from)?; + let mut dest = dest.to_vec(); + if !shift_after_removal(&mut dest, from) { + return None; + } + let mut chain = from.get(..from.len().checked_sub(1)?)?.to_vec(); + // (a group the row is going into stays, however empty it is for now) + while !chain.is_empty() && !(dest.len() > chain.len() && dest.starts_with(&chain)) && list_at(&next, &chain).is_some_and(|l| l.rules.is_empty()) { + remove_at(&mut next, &chain)?; + if !shift_after_removal(&mut dest, &chain) { + return None; + } + chain.pop(); + } + insert_at(&mut next, &dest, node)?; + *rs = next; + Some(dest) +} + +/// The place an arrow key sends the row at `from` to, if there is one (the move may still be +/// refused: [`judge`]). +pub fn key_dest(rs: &RuleSet, from: &[usize], dir: Dir) -> Option> { + let (&i, parent) = from.split_last()?; + let list = list_at(rs, parent)?; + // a row that isn't there has no place to go (and `i` is then below `len`, so `i + 1` can't overflow) + list.rules.get(i)?; + let at = |n: usize| parent.iter().copied().chain([n]).collect::>(); + match dir { + Dir::Up if i > 0 => Some(at(i - 1)), + // the first of a group: out, to just before the group + Dir::Up => (!parent.is_empty()).then(|| parent.to_vec()), + Dir::Down if i + 1 < list.rules.len() => Some(at(i + 2)), + // the last of a group, and ←: out, to just after the group + Dir::Down | Dir::Out => { + let (&g, outer) = parent.split_last()?; + Some(outer.iter().copied().chain([g + 1]).collect()) + } + Dir::In => { + let above = i.checked_sub(1).and_then(|a| match list.rules.get(a)? { + Rule::Group { group } => Some(at(a).into_iter().chain([group.rules.len()]).collect::>()), + Rule::Field { .. } => None, + }); + above.or_else(|| match list.rules.get(i + 1)? { + Rule::Group { .. } => Some(at(i + 1).into_iter().chain([0]).collect()), + Rule::Field { .. } => None, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn rs(v: Value) -> RuleSet { + serde_json::from_value(v).unwrap() + } + + /// Each rule is named by its rating, each group by `g` and what is in it: `1 2 (3 4) 5`. + fn shape(rs: &RuleSet) -> String { + let parts: Vec = rs + .rules + .iter() + .map(|r| match r { + Rule::Field { value, .. } => value.to_string(), + Rule::Group { group } => format!("({})", shape(group)), + }) + .collect(); + parts.join(" ") + } + + fn rule(n: u32) -> Value { + json!({"field": "rating", "op": "is", "value": n}) + } + + fn group(rules: Vec) -> Value { + json!({"group": {"match": "any", "rules": rules}}) + } + + fn set(rules: Vec) -> RuleSet { + rs(json!({"rules": rules})) + } + + fn moved(rules: Vec, from: &[usize], dest: &[usize]) -> (String, Option>) { + let mut r = set(rules); + let at = move_node(&mut r, from, dest); + (shape(&r), at) + } + + #[test] + fn a_rule_moves_down_and_up_in_its_list() { + let list = || vec![rule(1), rule(2), rule(3), rule(4)]; + assert_eq!(moved(list(), &[0], &[3]), ("2 3 1 4".into(), Some(vec![2])), "before the fourth"); + assert_eq!(moved(list(), &[0], &[4]), ("2 3 4 1".into(), Some(vec![3])), "to the end"); + assert_eq!(moved(list(), &[3], &[0]), ("4 1 2 3".into(), Some(vec![0])), "to the top"); + assert_eq!(moved(list(), &[2], &[1]), ("1 3 2 4".into(), Some(vec![1]))); + } + + #[test] + fn a_move_to_where_the_row_already_is_changes_nothing() { + let list = || vec![rule(1), rule(2), rule(3)]; + for dest in [[1], [2]] { + assert_eq!(judge(&set(list()), &[1], &dest), Verdict::Nothing); + assert_eq!(moved(list(), &[1], &dest), ("1 2 3".into(), None)); + } + } + + #[test] + fn a_rule_moves_into_and_out_of_a_group() { + let list = || vec![rule(1), group(vec![rule(2), rule(3)]), rule(4)]; + assert_eq!(moved(list(), &[0], &[1, 1]), ("(2 1 3) 4".into(), Some(vec![0, 1])), "into the group, between its rules"); + assert_eq!(moved(list(), &[2], &[1, 2]), ("1 (2 3 4)".into(), Some(vec![1, 2])), "into its end"); + assert_eq!(moved(list(), &[1, 0], &[0]), ("2 1 (3) 4".into(), Some(vec![0])), "out, to the top"); + assert_eq!(moved(list(), &[1, 1], &[3]), ("1 (2) 4 3".into(), Some(vec![3])), "out, to the end"); + } + + #[test] + fn a_group_left_empty_goes_away() { + let list = || vec![rule(1), group(vec![rule(2)]), rule(3)]; + assert_eq!(moved(list(), &[1, 0], &[0]), ("2 1 3".into(), Some(vec![0])), "the group's only rule goes before it"); + assert_eq!(moved(list(), &[1, 0], &[3]), ("1 3 2".into(), Some(vec![2])), "after everything"); + assert_eq!(moved(list(), &[1, 0], &[1]), ("1 2 3".into(), Some(vec![1])), "to the group's own place"); + // the emptied group is inside another: both go + let deep = vec![rule(1), group(vec![group(vec![rule(2)])]), rule(3)]; + assert_eq!(moved(deep, &[1, 0, 0], &[0]), ("2 1 3".into(), Some(vec![0]))); + // into a group, and the old group is gone: the places after it shift + let two = vec![group(vec![rule(1)]), group(vec![rule(2)])]; + assert_eq!(moved(two, &[0, 0], &[1, 1]), ("(2 1)".into(), Some(vec![0, 1]))); + } + + #[test] + fn a_group_moves_whole_and_never_into_itself() { + let list = || vec![group(vec![rule(1), rule(2)]), rule(3), group(vec![rule(4)])]; + assert_eq!(moved(list(), &[0], &[2]), ("3 (1 2) (4)".into(), Some(vec![1]))); + assert_eq!(moved(list(), &[0], &[3]), ("3 (4) (1 2)".into(), Some(vec![2])), "to the end"); + assert_eq!(moved(list(), &[0], &[2, 1]), ("3 (4 (1 2))".into(), Some(vec![1, 1])), "into the other group"); + for dest in [vec![0, 0], vec![0, 1], vec![0, 2]] { + assert_eq!(judge(&set(list()), &[0], &dest), Verdict::Refused(Refusal::IntoItself), "{dest:?}"); + assert_eq!(moved(list(), &[0], &dest), ("(1 2) 3 (4)".into(), None)); + } + let nested = || vec![group(vec![rule(1), group(vec![rule(2)])])]; + assert_eq!(judge(&set(nested()), &[0], &[0, 1, 0]), Verdict::Refused(Refusal::IntoItself), "inside what is inside it"); + } + + #[test] + fn groups_stay_within_the_depth_the_editor_offers() { + // a group holding a group holding a group: three lists below the top one + let tall = group(vec![group(vec![group(vec![rule(1)])])]); + let list = || vec![tall.clone(), group(vec![rule(2)])]; + // into the second group it would be four deep + assert_eq!(judge(&set(list()), &[0], &[1, 0]), Verdict::Refused(Refusal::TooDeep)); + assert_eq!(moved(list(), &[0], &[1, 0]).1, None); + // but beside it is fine, and so is a rule or a small group at any depth + assert_eq!(judge(&set(list()), &[0], &[2]), Verdict::Moves); + let flat = vec![rule(1), group(vec![group(vec![group(vec![rule(2)])])])]; + assert_eq!(judge(&set(flat.clone()), &[0], &[1, 0, 0, 0]), Verdict::Moves, "a rule can go into the deepest list"); + let small = vec![group(vec![rule(1)]), group(vec![group(vec![group(vec![rule(2)])])])]; + assert_eq!(judge(&set(small.clone()), &[0], &[1, 0, 0, 0]), Verdict::Refused(Refusal::TooDeep), "a group can't"); + assert_eq!(judge(&set(small), &[0], &[1, 0, 0]), Verdict::Moves, "one level up it can"); + } + + #[test] + fn a_group_that_is_already_too_deep_can_still_be_reordered() { + let too_deep = group(vec![group(vec![group(vec![group(vec![rule(1)]), group(vec![rule(2)])])])]); + let r = set(vec![too_deep]); + // the two innermost groups swap places: same depth, allowed + assert_eq!(judge(&r, &[0, 0, 0, 0], &[0, 0, 0, 2]), Verdict::Moves); + // moving one of them deeper is not + assert_eq!(judge(&r, &[0, 0, 0, 0], &[0, 0, 0, 1, 0]), Verdict::Refused(Refusal::TooDeep)); + } + + #[test] + fn paths_that_lead_nowhere_change_nothing() { + let r = || set(vec![rule(1), group(vec![rule(2)])]); + for (from, dest) in [ + (vec![], vec![0]), + (vec![0], vec![]), + (vec![5], vec![0]), + (vec![0], vec![9]), + (vec![0], vec![0, 0]), + (vec![0], vec![5, 0]), + (vec![1, 4], vec![0]), + (vec![usize::MAX], vec![0]), + (vec![0], vec![usize::MAX]), + (vec![1], vec![1, usize::MAX]), + ] { + let mut x = r(); + assert_eq!(move_node(&mut x, &from, &dest), None, "{from:?} -> {dest:?}"); + assert_eq!(x, r()); + } + } + + /// Given a path whose last step is past the end of its list (`usize::MAX` too), when an arrow + /// key asks where the row goes, then there is no place, and no index arithmetic overflows. + #[test] + fn arrow_keys_on_a_path_that_leads_nowhere_find_no_place() { + let r = set(vec![rule(1), group(vec![rule(2)])]); + for from in [vec![2], vec![5], vec![usize::MAX], vec![1, 1], vec![1, usize::MAX]] { + for dir in [Dir::Up, Dir::Down, Dir::Out, Dir::In] { + assert_eq!(key_dest(&r, &from, dir), None, "{from:?} {dir:?}"); + } + } + } + + #[test] + fn arrow_keys_move_a_row_one_place() { + let list = || vec![rule(1), group(vec![rule(2), rule(3)]), rule(4)]; + let key = |from: &[usize], dir| { + let mut r = set(list()); + let at = key_dest(&r, from, dir).and_then(|d| move_node(&mut r, from, &d)); + (shape(&r), at) + }; + assert_eq!(key(&[0], Dir::Down), ("(2 3) 1 4".into(), Some(vec![1])), "a group is passed, not entered"); + assert_eq!(key(&[2], Dir::Up), ("1 4 (2 3)".into(), Some(vec![1]))); + assert_eq!(key(&[0], Dir::Up), ("1 (2 3) 4".into(), None), "the top stays"); + assert_eq!(key(&[2], Dir::Down), ("1 (2 3) 4".into(), None), "the end stays"); + // at a group's ends, ↑ ↓ carry the row out of it + assert_eq!(key(&[1, 0], Dir::Up), ("1 2 (3) 4".into(), Some(vec![1]))); + assert_eq!(key(&[1, 1], Dir::Down), ("1 (2) 3 4".into(), Some(vec![2]))); + assert_eq!(key(&[1, 0], Dir::Down), ("1 (3 2) 4".into(), Some(vec![1, 1])), "inside, one place down"); + // ← : out, just after the group; at the top level nothing + assert_eq!(key(&[1, 0], Dir::Out), ("1 (3) 2 4".into(), Some(vec![2]))); + assert_eq!(key(&[0], Dir::Out), ("1 (2 3) 4".into(), None)); + // → : into the group above (its end) or below (its start) + assert_eq!(key(&[2], Dir::In), ("1 (2 3 4)".into(), Some(vec![1, 2]))); + assert_eq!(key(&[0], Dir::In), ("(1 2 3) 4".into(), Some(vec![0, 0]))); + let lone = || vec![rule(1), rule(2)]; + let mut r = set(lone()); + assert_eq!(key_dest(&r, &[0], Dir::In), None, "no group beside it"); + assert_eq!(move_node(&mut r, &[0], &[0]), None); + } + + #[test] + fn a_rows_only_rule_going_out_takes_its_group_with_it() { + let list = || vec![group(vec![rule(1)]), rule(2)]; + let mut r = set(list()); + let at = key_dest(&r, &[0, 0], Dir::Down).and_then(|d| move_node(&mut r, &[0, 0], &d)); + assert_eq!((shape(&r), at), ("1 2".into(), Some(vec![0])), "the group would be empty"); + let mut r = set(list()); + let at = key_dest(&r, &[0, 0], Dir::Out).and_then(|d| move_node(&mut r, &[0, 0], &d)); + assert_eq!((shape(&r), at), ("1 2".into(), Some(vec![0])), "← too: just after a group that is gone"); + } + + #[test] + fn a_row_leaving_nested_groups_may_leave_the_group_it_is_going_to_empty() { + let mut r = set(vec![group(vec![group(vec![rule(1)])])]); + assert_eq!(move_node(&mut r, &[0, 0, 0], &[0, 0]), Some(vec![0, 0])); + assert_eq!(shape(&r), "(1)"); + let mut r = set(vec![group(vec![group(vec![rule(1)])])]); + let dest = key_dest(&r, &[0, 0, 0], Dir::Out).unwrap(); + assert!(move_node(&mut r, &[0, 0, 0], &dest).is_some()); + assert_eq!(shape(&r), "(1)"); + } + + #[test] + fn arrow_keys_respect_the_depth_limit() { + let tall = group(vec![group(vec![group(vec![rule(1)])])]); + let r = set(vec![tall.clone(), group(vec![rule(2)])]); + // → would put it in the group below: four deep + let dest = key_dest(&r, &[0], Dir::In).unwrap(); + assert_eq!(judge(&r, &[0], &dest), Verdict::Refused(Refusal::TooDeep)); + } +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..ef9ebf85d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -3,7 +3,8 @@ //! //! Changes apply immediately (no OK/Cancel). Where they are stored: //! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, -//! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in +//! preview size, filmstrip/grid badges, last library, monitor profile, whether Import Lightroom +//! Catalog… asks where a catalog goes) and Auto Advance live in //! the UI state, //! saved by the host in its config folder (`ui.json`); //! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow @@ -36,12 +37,52 @@ const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; const LABEL_W: f32 = 150.0; -/// The dialog body for `tab` (the tab bar switches `tab`). -pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { +/// The page of `tab` (the tab bar in [`window`] switches `tab`). +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &str) { let t = Tokens::get(ui.ctx()); - ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + match tab { + "import" => import_tab(app, ui, &t), + "performance" => performance_tab(app, ui, &t), + "display" => display_tab(app, ui, &t), + "interface" => interface_tab(app, ui, &t), + "slideshow" => slideshow_tab(app, ui, &t, true), + "faces" => super::faces::settings_tab(app, ui, &t), + "denoise" => super::denoise::settings_tab(app, ui, &t), + _ => general_tab(app, ui, &t), + } +} + +/// The widest the Settings window's content gets, and the tallest its pages get: the window is +/// one size on every tab, so it stays where it is as the tabs are gone through. +const PAGE_SIZE: egui::Vec2 = egui::vec2(560.0, 640.0); + +/// The least room in which a page scrolls by itself. In a window with less (far below the +/// desktop minimum), the tabs and the page scroll together in the dialog's own scroll area. +const PAGE_MIN_HEIGHT: f32 = 120.0; + +/// The share of the page's room the pinned slide preview of the Slideshow tab takes, when the page +/// has more room than it needs to keep [`PAGE_MIN_HEIGHT`] for itself. +const PINNED_PREVIEW_SHARE: f32 = 0.4; + +/// What the preview's heading, the one-line hint under the slide, its Photo / Intro / Ending +/// choice and the rule under it take around the slide (at the default spacing, about 82 pt). +const PREVIEW_CHROME: f32 = 82.0; + +/// The Settings window's content: the tab bar, which stays put, above the page of `tab` +/// ([`body`]), which scrolls when it is taller than the room for it, with a scroll handle that +/// shows without the pointer on it. `max_height` is the room the app window leaves for both: the +/// two together never take more, so the scroll area every dialog is drawn in has nothing to +/// scroll (but for a window with less than [`PAGE_MIN_HEIGHT`] for the page, where that area +/// scrolls the two together). Every page gets the same room whatever it holds (but for the pinned +/// preview of Slideshow), and each keeps a scroll position of its own. Nothing here is wider than the window from 640 pt of app window +/// up: a row's controls wrap ([`row`]) and paths are shortened ([`path_label`]). +/// On Slideshow the slide preview is pinned between the tab bar and the page (where the room +/// allows), and comes off that page's room. +pub fn window(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String, max_height: f32) { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, PAGE_SIZE.x)); + let top = ui.cursor().top(); ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; + ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { *tab = id.to_string(); @@ -49,17 +90,99 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { } }); ui.separator(); - ui.add_space(2.0); - match tab.as_str() { - "import" => import_tab(app, ui, &t), - "performance" => performance_tab(app, ui, &t), - "display" => display_tab(app, ui, &t), - "interface" => interface_tab(app, ui, &t), - "slideshow" => slideshow_tab(app, ui, &t), - "faces" => super::faces::settings_tab(app, ui, &t), - "denoise" => super::denoise::settings_tab(app, ui, &t), - _ => general_tab(app, ui, &t), + // (a point less than the room left, so rounding never makes the dialog's own scroll area scroll) + let room = max_height - (ui.cursor().top() - top) - 1.0; + if room < PAGE_MIN_HEIGHT { + body(app, ui, tab.as_str()); + register(ui.ctx(), "settings:page", ui.min_rect()); + return; } + let mut height = room.min(PAGE_SIZE.y); + // The slide preview stays above the page on Slideshow: hovering a template in the Template + // Browser previews it there, so it has to stay in view however far down the templates are + // scrolled to. With its heading and choice it takes [`PINNED_PREVIEW_SHARE`] of the room + // (at least the least tall slide); where that leaves less than [`PAGE_MIN_HEIGHT`] for the + // page, it scrolls with the page instead (as in the short window above). + let mut pinned = false; + if tab == "slideshow" { + let slide_h = (height * PINNED_PREVIEW_SHARE - PREVIEW_CHROME).max(super::slide_layout::PREVIEW_MIN_H); + if height - (slide_h + PREVIEW_CHROME) >= PAGE_MIN_HEIGHT { + let before = ui.cursor().top(); + super::slide_layout::preview(app, ui, &Tokens::get(ui.ctx()), slide_h); + ui.add_space(2.0); + ui.separator(); + height = (height - (ui.cursor().top() - before)).max(PAGE_MIN_HEIGHT); + pinned = true; + } + } + // A page that goes on below shows its scroll handle all the time: without it, a page a line + // or two too long looks cut off, not scrollable. + ui.spacing_mut().scroll.dormant_handle_opacity = 0.6; + let page = egui::ScrollArea::vertical() + .id_salt(("settings-page", tab.as_str())) + .max_height(height) + .min_scrolled_height(height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(2.0); + if pinned { + slideshow_tab(app, ui, &Tokens::get(ui.ctx()), false); + } else { + body(app, ui, tab.as_str()); + } + // (what the page took: wider than the room for it would mean a control is cut off) + register(ui.ctx(), "settings:page", ui.min_rect()); + }); + register(ui.ctx(), "settings:pageRoom", page.inner_rect); +} + +/// `path` shortened to fit `max` (as `width` measures it) by dropping leading folders, after +/// either kind of separator, so that its end stays (`…/Pictures/Library`, +/// `…\Pictures\Library`): the end says which folder it is. A last folder too long by itself +/// loses its end ([`crate::widgets::elide_head`]). +fn elide_path(path: &str, max: f32, width: impl Fn(&str) -> f32) -> String { + if width(path) <= max { + return path.to_string(); + } + // (a separator at the very end is not where a name starts: `…/` alone would say nothing) + let end = path.trim_end_matches(['/', '\\']).len(); + let mut rest = path; + while let Some(i) = rest.find(['/', '\\']).filter(|i| path.len() - rest.len() + i + 1 < end) { + let separator = if rest.get(i..).is_some_and(|r| r.starts_with('\\')) { '\\' } else { '/' }; + rest = rest.get(i + 1..).unwrap_or(""); + let shorter = format!("…{separator}{rest}"); + if width(&shorter) <= max { + return shorter; + } + } + let name = rest.trim_end_matches(['/', '\\']); + // (nothing but separators: never an empty label) + if name.is_empty() { "…".to_string() } else { crate::widgets::elide_head(name, max, width) } +} + +/// A folder or file path on one line in the room left in its row (`text:{id}`): shortened from +/// the front when it is too long ([`elide_path`]), with all of it in a tooltip then. `after` is +/// the width of what follows it on the line (a button, a note) and its spacing: the path leaves +/// that room, so those never wrap onto a second line. +fn path_label(ui: &mut egui::Ui, id: &str, path: &str, font: egui::FontId, color: egui::Color32, after: f32) { + let shown = + elide_path(path, (ui.available_width() - after).max(0.0), |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x); + // (`truncate` is the last resort, for a room narrower than "…") + let r = ui.add(egui::Label::new(RichText::new(&shown).font(font).color(color)).truncate().show_tooltip_when_elided(false)); + register(ui.ctx(), format!("text:{id}"), r.rect); + if shown != path { + r.on_hover_text(path); + } +} + +/// The width of `text` in the style `style`, and the gap before it: what a label takes in a row. +fn label_width(ui: &egui::Ui, text: &str, style: egui::TextStyle) -> f32 { + ui.painter().layout_no_wrap(text.to_string(), style.resolve(ui.style()), egui::Color32::WHITE).size().x + ui.spacing().item_spacing.x +} + +/// The same for a button, which has padding either side of its text. +fn button_width(ui: &egui::Ui, text: &str) -> f32 { + label_width(ui, text, egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.x } pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { @@ -71,9 +194,17 @@ pub(super) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnc ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); - ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label)); + // (a label longer than its column wraps in it; it does not push the controls along) + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(label)).color(t.text_label)).wrap()); }); - add(ui) + // The controls wrap in the room beside the label, each further line starting under the + // first control, so a row with many choices grows down and never widens the window. + let wrapped = egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 24.0), wrapped, |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + add(ui) + }) + .inner }) .inner } @@ -119,7 +250,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { None => crate::i18n::tr("In memory — nothing is saved").to_string(), }; row(ui, t, crate::i18n::tr("Location"), |ui| { - ui.label(RichText::new(location).color(t.text)); + path_label(ui, "settingsLocation", &location, egui::TextStyle::Body.resolve(ui.style()), t.text, 0.0); }); row(ui, t, "", |ui| { let can = app.services.pick_folder.is_some(); @@ -137,12 +268,36 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { hint(ui, t, crate::i18n::tr("not available here")); } }); + // where libraries are folders to choose from (not in the browser, which keeps one) + if app.services.pick_folder.is_some() { + use crate::state::LaunchLibrary; + row(ui, t, crate::i18n::tr("At launch"), |ui| { + choices( + ui, + "settingsAtLaunch", + &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], + &mut app.ui.settings.at_launch, + ); + }); + hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); + } + // File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not + ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label)); + row(ui, t, "", |ui| { + use crate::state::LightroomImport; + choices( + ui, + "settingsLightroomImport", + &[(LightroomImport::Ask, "Ask where to import it"), (LightroomImport::AlwaysMerge, "Always merge into the open library")], + &mut app.ui.settings.lightroom_import, + ); + }); heading(ui, t, crate::i18n::tr("Startup")); row(ui, t, crate::i18n::tr("Open in"), |ui| { choices( ui, "settingsStartup", - &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")], + &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail View")], &mut app.ui.settings.startup_view, ); }); @@ -150,6 +305,8 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); + heading(ui, t, crate::i18n::tr("Dialogs")); + check(ui, "settings.closeDialogsOutside", &mut app.ui.settings.close_dialogs_outside, "Close a dialog by clicking outside it"); heading(ui, t, crate::i18n::tr("External Editing")); row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) @@ -366,6 +523,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); if !cfg!(target_arch = "wasm32") { + heading(ui, t, crate::i18n::tr("Dropped Files")); + check(ui, "settings.importDropped", &mut app.ui.settings.import_dropped_directly, "Import dropped files without review"); heading(ui, t, crate::i18n::tr("Cameras and Cards")); let mut on_card = d.show_on_card; if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { @@ -374,7 +533,17 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Auto Import")); hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); row(ui, t, crate::i18n::tr("Watched folder"), |ui| { - ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| crate::i18n::tr("Off").into())).color(t.text)); + match &d.auto_folder { + Some(folder) => path_label( + ui, + "settingsAutoFolder", + folder, + egui::TextStyle::Body.resolve(ui.style()), + t.text, + button_width(ui, crate::i18n::tr("Choose…")) + button_width(ui, crate::i18n::tr("Turn Off")), + ), + None => drop(ui.label(RichText::new(crate::i18n::tr("Off")).color(t.text))), + } let can = app.services.pick_folder.is_some(); let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…"))); register(ui.ctx(), "button:settingsAutoFolder", r.rect); @@ -510,8 +679,18 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let available = loc["available"].as_bool().unwrap_or(true); let custom = loc["custom"].as_bool().unwrap_or(false); row(ui, t, crate::i18n::tr("Folder"), |ui| { - let text = format!("{path}{}", if custom { "" } else { " (library default)" }); - ui.add(egui::Label::new(RichText::new(text.clone()).color(if available { t.text } else { t.reject })).truncate()).on_hover_text(text); + path_label( + ui, + "settingsSmartFolder", + &path, + egui::TextStyle::Body.resolve(ui.style()), + if available { t.text } else { t.reject }, + if custom { 0.0 } else { label_width(ui, "(library default)", egui::TextStyle::Body) }, + ); + if !custom { + let r = ui.label(RichText::new("(library default)").color(t.text)); + register(ui.ctx(), "text:settingsSmartDefault", r.rect); + } }); let (count, bytes) = (loc["count"].as_u64().unwrap_or(0), loc["bytes"].as_u64().unwrap_or(0)); if available { @@ -627,7 +806,7 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let info = d.describe(); if !screen { row(ui, t, crate::i18n::tr("File"), |ui| { - ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim)); + path_label(ui, "settingsDisplayFile", &d.path, egui::FontId::proportional(11.0), t.text_dim, 0.0); }); } row(ui, t, crate::i18n::tr("Type"), |ui| { @@ -681,7 +860,7 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { t, crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), ); - heading(ui, t, crate::i18n::tr("Detail")); + heading(ui, t, crate::i18n::tr("Detail View")); check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); row(ui, t, crate::i18n::tr("Info overlay"), |ui| { use crate::state::InfoOverlay as I; @@ -748,44 +927,32 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s } } -/// Width of the Slideshow tab's settings column (the slide preview is beside it). -const SLIDESHOW_COLUMN_W: f32 = 480.0; - -fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { - // the settings scroll on the left, the composed slide stays in view on the right - let top_down = egui::Layout::top_down(egui::Align::Min); - // as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt) - let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0); - ui.horizontal_top(|ui| { - ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| { - ui.set_width(SLIDESHOW_COLUMN_W); - egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| { - super::slide_templates::section(app, ui, t); - ui.add_space(6.0); - playback_section(app, ui, t); - ui.add_space(6.0); - super::slide_layout::sections(app, ui, t); - ui.add_space(6.0); - super::slide_overlays::sections(app, ui, t); - ui.add_space(6.0); - super::slide_titles::sections(app, ui, t); - ui.add_space(6.0); - export_section(app, ui, t); - }); - }); - ui.add_space(12.0); - ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| { - ui.set_width(super::slide_layout::PREVIEW_W); - super::slide_layout::preview(app, ui, t) - }); - }); +/// The Slideshow tab: the slide preview, then the settings, in one column the width of the page +/// (the page scrolls; a column beside the preview would be wider than the window). Without +/// `with_preview` the preview is left out: [`window`] has pinned it above the page. +fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, with_preview: bool) { + if with_preview { + super::slide_layout::preview(app, ui, t, f32::INFINITY); + ui.add_space(6.0); + } + super::slide_templates::section(app, ui, t); + ui.add_space(6.0); + playback_section(app, ui, t); + ui.add_space(6.0); + super::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + super::slide_overlays::sections(app, ui, t); + ui.add_space(6.0); + super::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); } /// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Export")); let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (id, label, format) in [ ("slideshowExportPdf", "Export PDF…", "pdf"), ("slideshowExportJpeg", "Export JPEG…", "jpeg"), @@ -953,12 +1120,7 @@ fn theme_card( }); register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); if favorite || hovered { - crate::icons::paint( - ui.painter(), - r.rect.shrink(4.0), - if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, - if favorite { t.accent } else { t.text_dim }, - ); + crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); } if r.clicked() { if favorite { @@ -1284,7 +1446,7 @@ fn valid_library_name(s: &str) -> Result<&str, String> { } /// `base` inside `location`, or `base 2`, `base 3`… if that is taken. -fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { +pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { let first = location.join(base); if !first.exists() { return first; @@ -1292,27 +1454,55 @@ fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) } -/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as -/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear` -/// forgets them all. +/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest +/// first, as `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last +/// background check found (`false`: moved, on a disconnected drive, not a folder or no longer a +/// library; `null`: not checked yet, or the check couldn't tell — a folder that can't be listed, an +/// unreadable catalog, a lookup or check that failed; `checking` says whether a check is running): the command never +/// looks at a folder itself, as one on an offline network share can take seconds to answer. +/// `lastOpened`: ISO 8601 time, `null` when not known. `remove` forgets that +/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches +/// anything on disk (issue #1290). pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + match p.get("remove") { + None | Some(Value::Null) => {} + Some(Value::String(path)) => { + if !forget_library(&mut app.ui.settings, path) { + return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); + } + app.recent_checks.forget(path); + } + Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), + } if p.get("clear").and_then(Value::as_bool) == Some(true) { - app.ui.settings.recent_libraries.clear(); + for path in std::mem::take(&mut app.ui.settings.recent_libraries) { + app.recent_checks.forget(&path); + } + app.ui.settings.recent_opened.clear(); } let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let list: Vec = app - .ui - .settings + let (settings, checks) = (&app.ui.settings, &app.recent_checks); + let list: Vec = settings .recent_libraries .iter() .map(|r| { let d = std::path::Path::new(r); - json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)}) + let exists = checks.exists(r); + json!({"path": r, "name": library_name(d), "exists": exists, "checking": checks.is_checking(r), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) }) .collect(); Ok(Value::Array(list)) } +/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is +/// left where it is. False if the list didn't hold it. +pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { + let before = settings.recent_libraries.len(); + settings.recent_libraries.retain(|r| r != path); + settings.recent_opened.remove(path); + settings.recent_libraries.len() != before +} + // ---------------------------------------------------------------------------- Display profile /// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile @@ -1416,3 +1606,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod path_tests { + use super::elide_path; + + /// Width = characters, so a limit of 20 is "20 characters". + fn fit(path: &str, max: usize) -> String { + elide_path(path, max as f32, |s| s.chars().count() as f32) + } + + #[test] + fn a_path_that_fits_is_left_alone() { + assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures"); + assert_eq!(fit("", 5), ""); + } + + #[test] + fn leading_folders_go_first_and_the_end_stays() { + assert_eq!(fit("/Users/me/Pictures/Library", 20), "…/Pictures/Library"); + assert_eq!(fit("/Users/me/Pictures/Library", 10), "…/Library"); + } + + /// Windows paths: `Path::display` gives backslashes there. + #[test] + fn a_path_with_backslashes_keeps_its_end_too() { + assert_eq!(fit(r"C:\Users\me\Pictures\Library", 20), r"…\Pictures\Library"); + assert_eq!(fit(r"C:\Users\me/Pictures\Library", 10), r"…\Library"); + } + + #[test] + fn a_last_folder_too_long_by_itself_loses_its_end() { + assert_eq!(fit("/Users/me/a-very-long-folder-name", 8), "a-very-…"); + assert_eq!(fit("no-separator-at-all", 8), "no-sepa…"); + assert_eq!(fit("/Users/Фотографии-из-отпуска", 8), "Фотогра…"); + } + + #[test] + fn a_separator_at_the_end_is_not_where_the_name_starts() { + assert_eq!(fit("/Users/me/Pictures/Library/", 11), "…/Library/"); + assert_eq!(fit("/Users/me/a-very-long-folder-name/", 8), "a-very-…"); + assert_eq!(fit(r"C:\Users\me\Library\", 11), r"…\Library\"); + assert_eq!(fit("///", 2), "…"); + assert_eq!(fit(r"\\server\share\folder", 15), r"…\share\folder"); + } +} diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs index 62f7626c3..802e62a5c 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -22,8 +22,10 @@ use crate::widgets::register; /// Seconds the layout must stay unchanged before the preview is rendered again. pub const DEBOUNCE: f64 = 0.15; -/// The preview's width in points. +/// The preview's greatest width in points (less where the room is narrower). pub const PREVIEW_W: f32 = 300.0; +/// The least tall the preview is drawn. +pub const PREVIEW_MIN_H: f32 = 80.0; #[derive(Clone, Copy)] enum Unit { @@ -162,27 +164,49 @@ fn screen_aspect(ctx: &egui::Context) -> f64 { if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 } } -/// The small composed slide of the active photo, with the margin guides when they are on. -pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { +/// The small composed slide of the active photo, with the margin guides when they are on. It is +/// at most `max_h` tall (but for [`PREVIEW_MIN_H`], and without counting its heading). +pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, max_h: f32) { heading(ui, t, "Preview"); // the template the pointer is over in the Template Browser, else the settings let template = super::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); let look = template.as_ref().map_or_else(|| SlideLook::of(&app.session), |t| t.look.clone()); let s = look.layout.clone(); let overlays = look.overlays.clone(); + // The room for the preview is the settings' own shape, whichever template is pointed at: the + // page below it must not move as the pointer goes over the templates (it would leave the + // row it is over). A template of another shape is drawn as large as fits in that room. let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32; - let h = (PREVIEW_W / ratio).clamp(80.0, 320.0); - let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover()); + let own = SlideLook::of(&app.session).layout.aspect.ratio(screen_aspect(ui.ctx())) as f32; + let w = ui.available_width().clamp(1.0, PREVIEW_W); + let room_h = (w / own).clamp(PREVIEW_MIN_H, 320.0).min(max_h.max(PREVIEW_MIN_H)); + let (room, _) = ui.allocate_exact_size(vec2(w, room_h), Sense::hover()); + let (fit_w, fit_h) = if ratio >= w / room_h { (w, w / ratio) } else { (room_h * ratio, room_h) }; + let rect = Rect::from_center_size(room.center(), vec2(fit_w, fit_h)); + let h = rect.height(); + let w = rect.width(); register(ui.ctx(), "slideshow:preview", rect); - let p = ui.painter_at(rect.expand(1.0)); + let p = ui.painter_at(room.expand(1.0)); p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); super::slide_titles::preview_choice(ui); if let Some(tpl) = &template { + // (said over the bottom of the preview and not below it, which would move the page) let name = if tpl.builtin { crate::i18n::tr(&tpl.name).to_string() } else { tpl.name.clone() }; - ui.label(egui::RichText::new(crate::i18n::tr_format!("Template preview: {name}", name = name)).size(11.0).color(t.accent)); + let mut said = crate::i18n::tr_format!("Template preview: {name}", name = name).to_string(); if !tpl.description.is_empty() { - hint(ui, t, &tpl.description); + said = format!("{said}\n{}", tpl.description); } + let galley = ui.painter().layout(said, t.font(11.0), Color32::WHITE, (room.width() - 8.0).max(1.0)); + let band = Rect::from_min_size( + pos2(room.left(), (room.bottom() - galley.size().y - 6.0).max(room.top())), + vec2(room.width(), galley.size().y + 6.0), + ); + let paint = ui + .ctx() + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("settingsSlideshowTemplateSaid"))) + .with_clip_rect(room.intersect(ui.clip_rect())); + paint.rect_filled(band, 0.0, Color32::from_black_alpha(190)); + paint.galley(pos2(band.left() + 4.0, band.top() + 3.0), galley, Color32::WHITE); } if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { title_preview(app, ui, t, rect, title, &look); @@ -194,7 +218,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { return; }; let ppp = ui.ctx().pixels_per_point(); - let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); + let (pw, ph) = (((w * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); // the overlays and what they show of the photo (its rating, its metadata) change it too let shown = app.session.catalog.photo(id).filter(|_| overlays.any()).map_or(0, |p| { use std::hash::{Hash, Hasher}; diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index a18e1f691..d1c94f142 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -141,10 +141,25 @@ fn paper_label(name: &str) -> String { "legal" => "Legal".into(), "tabloid" => "Tabloid".into(), "custom" => "Custom".into(), + // Japanese photo and postcard papers: the customary name and its metric size. + "l" => "L判 (89 × 127 mm)".into(), + "2l" => "2L判 (127 × 178 mm)".into(), + "kg" => "KG判 (102 × 152 mm)".into(), + "postcard" => "はがき (100 × 148 mm)".into(), + "double-postcard" => "往復はがき (148 × 200 mm)".into(), + "6cut" => "六切 (203 × 254 mm)".into(), + "4cut" => "四切 (254 × 305 mm)".into(), + "business-card" => "名刺 (55 × 91 mm)".into(), other => other.replace('x', " × ") + " in", } } +/// The paper the selector shows as current: `custom`, or the canonical name an alias stands for, +/// so a `print.set` with `はがき` still selects the `postcard` option. +fn current_paper(paper: &str) -> &str { + if paper.eq_ignore_ascii_case("custom") { "custom" } else { layout::paper_name(paper).unwrap_or(paper) } +} + /// The dialog's body: the settings column and the page preview side by side. pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { let ctx = ui.ctx().clone(); @@ -374,8 +389,8 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, heading(ui, t, "Page Setup"); row(ui, t, "Paper", |ui| { - let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; - let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(¤t)).show_ui(ui, |ui| { + let current = current_paper(&l.page.paper); + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(current)).show_ui(ui, |ui| { for name in layout::paper_names().chain(std::iter::once("custom")) { if ui.selectable_label(current == name, paper_label(name)).clicked() { patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); @@ -856,6 +871,8 @@ fn paint_cell( clip.add(egui::Shape::mesh(mesh)); } None => { + // the page is paper in every theme (its colour is the print's, not the theme's), so + // these stay fixed greys: a photo still to come, and an empty cell clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); } } @@ -959,5 +976,17 @@ mod tests { assert_eq!(trim(2.5), "2.5"); assert_eq!(paper_label("a4"), "A4"); assert_eq!(paper_label("4x6"), "4 × 6 in"); + // The Japanese photo and postcard papers show their customary name and metric size. + assert_eq!(paper_label("l"), "L判 (89 × 127 mm)"); + assert_eq!(paper_label("postcard"), "はがき (100 × 148 mm)"); + assert_eq!(paper_label("double-postcard"), "往復はがき (148 × 200 mm)"); + assert_eq!(paper_label("6cut"), "六切 (203 × 254 mm)"); + assert_eq!(paper_label("4cut"), "四切 (254 × 305 mm)"); + // The selector resolves an alias stored by print.set to the canonical option. + assert_eq!(current_paper("はがき"), "postcard"); + assert_eq!(current_paper("L判"), "l"); + assert_eq!(current_paper("Custom"), "custom"); + assert_eq!(paper_label(current_paper("はがき")), "はがき (100 × 148 mm)"); + assert_eq!(paper_label(current_paper("a4")), "A4"); } } diff --git a/crates/ui-egui/src/progress.rs b/crates/ui-egui/src/progress.rs new file mode 100644 index 000000000..0f2dce012 --- /dev/null +++ b/crates/ui-egui/src/progress.rs @@ -0,0 +1,191 @@ +//! Work in progress, shown one way everywhere: a [`Progress`] bar (a known fraction, or a gentle +//! sweep when nothing is known yet) and a [`spinner`] for short waits beside text or in a busy +//! [`crate::button::Button`]. It replaces `egui::ProgressBar` and `ui.spinner()`. +//! +//! ```ignore +//! Progress::new(Some(done as f32 / total as f32)).label(text).widget("denoise:progress").show(ui); +//! Progress::new(None).label(tr("Starting…")).show(ui); +//! ui.horizontal(|ui| { spinner(ui, 14.0); ui.label(tr("Selecting…")); }); +//! ``` +//! +//! A bar announces itself to screen readers as a progress indicator with its percentage. Motion +//! asks for repaints only while it is on screen. There is no reduced-motion setting in the app, +//! so the motion is kept slow and small: one gentle sweep, one slow turning arc. + +use std::f32::consts::TAU; + +use egui::{Color32, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, pos2, vec2}; + +use crate::theme::Tokens; + +/// A bar in a model row: it leaves the row's Cancel button its place. +pub const ROW_WIDTH: f32 = 260.0; +/// The narrowest a bar that fills the available width is drawn. +const MIN_WIDTH: f32 = 96.0; +/// Height of a bar that carries text. +const HEIGHT_WITH_TEXT: f32 = 18.0; +/// Height of a bar without text. +const HEIGHT_PLAIN: f32 = 6.0; +/// Seconds an indeterminate bar takes to sweep across and back. +const SWEEP_SECONDS: f64 = 1.8; +/// Seconds a spinner takes for one turn. +const TURN_SECONDS: f64 = 1.2; +/// How often moving things redraw. +const FRAME: std::time::Duration = std::time::Duration::from_millis(33); + +/// `fraction` as a number from 0 to 1: NaN counts as nothing done, and out-of-range values stop at +/// the ends. +pub fn clamp_fraction(fraction: f32) -> f32 { + if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) } +} + +/// `done` of `total` as a fraction, or `None` while the total is not known (0): an unknown total +/// is a bar that sweeps, not one stuck at 0 %. +pub fn fraction_of(done: u64, total: u64) -> Option { + (total > 0).then(|| clamp_fraction(done as f32 / total as f32)) +} + +/// The whole percent a bar says for `fraction`: rounded down, so 100 comes only at exactly 1.0, but +/// with a little slack so a fraction that is exact in decimals (53 of 100, held as 0.52999997) +/// still says 53. A bar's text and its accessible value both come from here, so they agree. +pub fn percent(fraction: f32) -> u32 { + let f = clamp_fraction(fraction); + let whole = (f64::from(f) * 100.0 + 1e-3).floor() as u32; + if f < 1.0 { whole.min(99) } else { 100 } +} + +/// A progress bar, built and then shown with [`Progress::show`]. +#[derive(Clone, Debug)] +pub struct Progress { + fraction: Option, + label: Option, + widget: Option, + width: Option, + dimmed: bool, +} + +impl Progress { + /// `Some(fraction)` of the work done (clamped to 0..=1, NaN as 0), or `None` while it is not + /// known how much there is. + pub fn new(fraction: Option) -> Self { + Progress { fraction: fraction.map(clamp_fraction), label: None, widget: None, width: None, dimmed: false } + } + + /// The fraction drawn, once clamped (`None` for an indeterminate bar). + pub fn fraction(&self) -> Option { + self.fraction + } + + /// Text on the bar (a percentage, a count, a file's name), already translated. + pub fn label(mut self, text: impl Into) -> Self { + self.label = Some(text.into()); + self + } + + /// Registered under this id for scripts, tests and `ui.clickWidget` (e.g. `progress:samDownload`). + pub fn widget(mut self, id: impl Into) -> Self { + self.widget = Some(id.into()); + self + } + + /// Drawn in the quiet text colour instead of the accent (work that is being stopped). + pub fn dimmed(mut self, dimmed: bool) -> Self { + self.dimmed = dimmed; + self + } + + /// This wide; by default the bar fills the width the layout has left. + pub fn width(mut self, width: f32) -> Self { + self.width = Some(width); + self + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let t = Tokens::get(ui.ctx()); + let label = self.label.as_deref().filter(|s| !s.is_empty()); + let width = self.width.unwrap_or_else(|| ui.available_size_before_wrap().x.max(MIN_WIDTH)); + let height = if label.is_some() { HEIGHT_WITH_TEXT } else { HEIGHT_PLAIN }; + let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); + resp.widget_info(|| { + let mut info = match label { + Some(l) => egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, ui.is_enabled(), l), + None => egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator), + }; + info.value = self.fraction.map(|f| f64::from(percent(f))); + info + }); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + if !ui.is_rect_visible(rect) { + return resp; + } + let p = ui.painter_at(rect); + let radius = height / 2.0; + p.rect_filled(rect, radius, t.inset); + p.rect_stroke(rect, radius, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let inner = rect.shrink(1.0); + let fill = match self.fraction { + Some(f) => Rect::from_min_size(inner.min, vec2(inner.width() * f, inner.height())), + None => { + // a short bar sweeping across and back, eased so it slows at the ends + let phase = (ui.input(|i| i.time) / SWEEP_SECONDS).fract() as f32; + let eased = 0.5 - 0.5 * (phase * TAU).cos(); + let len = inner.width() * 0.3; + let x = inner.left() + (inner.width() - len) * eased; + ui.ctx().request_repaint_after(FRAME); + Rect::from_min_size(pos2(x, inner.top()), vec2(len, inner.height())) + } + }; + if fill.width() > 0.0 { + p.with_clip_rect(inner).rect_filled(fill, radius - 1.0, if self.dimmed { t.text_dim } else { t.accent }); + } + if let Some(l) = label { + // the text reads on the track in the theme's colour, and in white where the fill is + let font = t.font(11.5); + let on_track = p.layout_no_wrap(l.to_string(), font.clone(), t.text); + let on_fill = p.layout_no_wrap(l.to_string(), font, Color32::WHITE); + let at = pos2(rect.center().x - on_track.size().x / 2.0, rect.center().y - on_track.size().y / 2.0); + let clipped = ui.painter_at(rect); + clipped.galley(at, on_track, t.text); + clipped.with_clip_rect(rect.intersect(fill)).galley(at, on_fill, Color32::WHITE); + } + resp + } +} + +/// The arc a spinner draws at `time` seconds: points on a circle of `radius` around `center`, +/// covering three quarters of the turn and rotating once per [`TURN_SECONDS`]. +fn arc_points(center: Pos2, radius: f32, time: f64) -> Vec { + let start = ((time / TURN_SECONDS).fract() as f32) * TAU; + let steps = 24; + (0..=steps) + .map(|i| { + let a = start + TAU * 0.75 * (i as f32 / steps as f32); + center + vec2(a.cos(), a.sin()) * radius + }) + .collect() +} + +/// Draw a spinner of `size` points centred in `rect`, in `color`, at `time` seconds. Callers that +/// draw by hand (a busy button) use this; it asks for no repaint itself. +pub fn paint_spinner(p: &egui::Painter, rect: Rect, color: Color32, time: f64) { + let size = rect.width().min(rect.height()); + let width = (size / 8.0).max(1.5); + let radius = ((size - width) / 2.0).max(0.5); + p.add(Shape::line(arc_points(rect.center(), radius, time), Stroke::new(width, color))); +} + +/// A small turning arc beside text that says what is being waited for. It is drawn as a vector +/// arc, not a glyph, and asks for repaints only while it is on screen. +pub fn spinner(ui: &mut Ui, size: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator)); + if ui.is_rect_visible(rect) { + paint_spinner(ui.painter(), rect, t.text_dim, ui.input(|i| i.time)); + ui.ctx().request_repaint_after(FRAME); + } + resp +} diff --git a/crates/ui-egui/src/rating.rs b/crates/ui-egui/src/rating.rs new file mode 100644 index 000000000..16cd611d2 --- /dev/null +++ b/crates/ui-egui/src/rating.rs @@ -0,0 +1,256 @@ +//! One star rating for the whole interface: the clickable row of five stars ([`Rating`]) and the +//! painter-only helpers for stars drawn onto photos and captions ([`paint_rating`], [`paint_star`]). +//! It replaces `widgets::stars` and the private star-painting loops of the grid badge, the film +//! badges, the compare caption and the filter bar. +//! +//! ```ignore +//! if let Some(r) = Rating::new("bottom", photo.rating).show(ui) { … } +//! paint_rating(ui.painter(), pos, 3, RatingSize::Small, color); +//! ``` +//! +//! Hovering a star previews the rating a click would set; clicking the current rating clears it. +//! The row is one Tab stop (the star of the current rating, or the first when unrated); inside it, ← and → +//! lower and raise the rating (0 to 5) and the focus follows. + +use egui::{Color32, Key, Modifiers, Painter, Pos2, Rect, Sense, Ui, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::{announce_selected, register}; + +/// Number of stars in a rating. +const STARS: u8 = 5; + +/// How big a rating is drawn. Each size matches one of the renderers it replaced. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RatingSize { + /// Film badges: 8 px stars every 9 px. + Small, + /// Grid badges and compare captions: 12 px stars every 13 px. + Medium, + /// The clickable row (bottom bar, Info panel, filter bar): 14 px stars in 19 px cells. + Large, +} + +impl RatingSize { + /// Side of one painted star. + pub fn icon(self) -> f32 { + match self { + Self::Small => 8.0, + Self::Medium => 12.0, + Self::Large => 14.0, + } + } + + /// Distance from one painted star to the next in [`paint_rating`]. + pub fn step(self) -> f32 { + match self { + Self::Small => 9.0, + Self::Medium => 13.0, + Self::Large => 17.0, + } + } + + /// Side of one clickable cell in a [`Rating`] row. + fn cell(self) -> f32 { + self.step() + 2.0 + } +} + +/// Paint one star (outline or filled) centred in `rect`. +pub fn paint_star(painter: &Painter, rect: Rect, filled: bool, color: Color32) { + paint(painter, rect, if filled { Icon::StarFilled } else { Icon::Star }, color); +} + +/// Paint `rating` filled stars in a row, the first one's left edge at `pos.x` and its vertical centre +/// at `pos.y`. Returns the width the row takes (0 for no stars), so the caller can lay out what follows. +pub fn paint_rating(painter: &Painter, pos: Pos2, rating: u8, size: RatingSize, color: Color32) -> f32 { + let n = rating.min(STARS); + let side = size.icon(); + for i in 0..n { + let left = pos.x + f32::from(i) * size.step(); + paint_star(painter, Rect::from_min_size(pos2(left, pos.y - side / 2.0), vec2(side, side)), true, color); + } + f32::from(n) * size.step() +} + +/// What the stars show once hovering is taken into account: the hovered star's rating previews over +/// the current one. +fn star_lit(i: u8, rating: u8, hover: Option) -> bool { + i < hover.unwrap_or(rating) +} + +/// The rating a click on star `n` (1 to 5) sets: clicking the current rating clears it. +fn clicked_rating(current: u8, n: u8) -> u8 { + if current == n { 0 } else { n } +} + +/// The rating the arrow keys move to from `current`, if any. +fn nudged_rating(current: u8, left: bool, right: bool) -> Option { + match (left, right) { + (true, false) if current > 0 => Some(current - 1), + (false, true) if current < STARS => Some(current + 1), + _ => None, + } +} + +/// A row of five stars. Build it, then [`Rating::show`]. +pub struct Rating { + id: String, + rating: u8, + ids_prefix: Option, +} + +impl Rating { + /// A rating of `rating` (0 to 5) whose stars are reachable as `star:{id}:{n}`. + pub fn new(id: impl Into, rating: u8) -> Self { + Self { id: id.into(), rating: rating.min(STARS), ids_prefix: None } + } + + /// Register the stars under `{prefix}{n}` instead of `star:{id}:{n}`, for rows that already have + /// published automation ids (the filter bar's `filter:star3`). + pub fn automation_ids(mut self, prefix: impl Into) -> Self { + self.ids_prefix = Some(prefix.into()); + self + } + + fn widget_id(&self, n: u8) -> String { + match &self.ids_prefix { + Some(prefix) => format!("{prefix}{n}"), + None => format!("star:{}:{n}", self.id), + } + } + + fn star_id(&self, ui: &Ui, i: u8) -> egui::Id { + ui.id().with(("rating-star", &self.id, i)) + } + + /// Draw the row. Returns the new rating when a click or an arrow key changed it. + pub fn show(self, ui: &mut Ui) -> Option { + let t = Tokens::get(ui.ctx()); + let cell = RatingSize::Large.cell(); + let mut out = None; + let mut hover_n = None; + let mut focused_at = None; + let mut cells = [Rect::NOTHING; STARS as usize]; + // One Tab stop for the row, like a radio group: the star of the current rating (the first when + // unrated), plus whichever star already has the focus so it keeps it + let stop = self.rating.max(1) - 1; + for (i, slot) in (0..STARS).zip(cells.iter_mut()) { + let id = self.star_id(ui, i); + let (r, _) = ui.allocate_exact_size(vec2(cell, cell), Sense::hover()); + let sense = if i == stop || ui.memory(|m| m.has_focus(id)) { Sense::click() } else { Sense::CLICK }; + let resp = ui.interact(r, id, sense); + announce_selected(&resp, format!("{} ★", i + 1), i < self.rating); + register(ui.ctx(), self.widget_id(i + 1), r); + if resp.hovered() { + hover_n = Some(i + 1); + } + if resp.clicked() { + out = Some(clicked_rating(self.rating, i + 1)); + } + if resp.has_focus() { + focused_at = Some(i); + // ← and → step the rating instead of moving the focus to the neighbouring widget + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, ..Default::default() }); + }); + ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + *slot = r; + } + let focused = focused_at.is_some(); + if focused && out.is_none() { + let (left, right) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowLeft), i.consume_key(Modifiers::NONE, Key::ArrowRight))); + if left || right { + // egui picked the neighbour to focus from this same key before the lock above could apply (the + // first arrow after Tab): cancel that move + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + out = nudged_rating(self.rating, left, right); + if let Some(n) = out { + // keep the focus on the star that is now the row's Tab stop + let to = self.star_id(ui, n.max(1) - 1); + ui.memory_mut(|m| m.request_focus(to)); + } else if focused_at != Some(stop) { + // the rating changed from outside (undo, a shortcut, another photo): the focus follows the Tab stop + let to = self.star_id(ui, stop); + ui.memory_mut(|m| m.request_focus(to)); + } + } + // The hovered star also gets the icon-button hover square, so hovering the current rating (where + // the preview looks the same) still shows feedback. + let side = RatingSize::Large.icon(); + for (i, r) in (0..STARS).zip(cells) { + if hover_n == Some(i + 1) { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + let filled = star_lit(i, self.rating, hover_n); + paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(side, side)), filled, if filled { t.star } else { t.icon }); + } + out + } +} + +/// A frameless star toggle for favourites: filled when `favorite`, named `name` for screen readers and as +/// its tooltip, reachable as `widget` (a full automation id). A square one line tall. +pub fn favorite_button(ui: &mut Ui, widget: String, favorite: bool, name: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let side = ui.spacing().interact_size.y; + let (r, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click()); + announce_selected(&resp, name, favorite); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(r) { + register(ui.ctx(), widget, r); + } + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + if resp.has_focus() { + ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + let icon = RatingSize::Medium.icon(); + paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(icon, icon)), favorite, if favorite { t.star } else { t.icon }); + resp.on_hover_text(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_hovered_star_previews_its_rating() { + let lit = |rating, hover| (0..STARS).filter(|&i| star_lit(i, rating, hover)).count(); + assert_eq!(lit(2, None), 2, "no hover shows the rating"); + assert_eq!(lit(2, Some(4)), 4); + assert_eq!(lit(5, Some(1)), 1); + assert_eq!(lit(0, Some(3)), 3); + } + + #[test] + fn arrows_stay_between_zero_and_five() { + assert_eq!(nudged_rating(0, true, false), None); + assert_eq!(nudged_rating(5, false, true), None); + assert_eq!(nudged_rating(3, true, false), Some(2)); + assert_eq!(nudged_rating(3, false, true), Some(4)); + assert_eq!(nudged_rating(3, true, true), None); + } + + #[test] + fn rows_scrolled_out_of_view_are_not_registered() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 100.0))), ..Default::default() }, |ui| { + egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| { + for i in 0..30 { + favorite_button(ui, format!("effectStar:{i}"), false, "Favorite"); + } + }); + }); + out.textures_delta.clear(); + let ids: Vec = crate::widgets::take_registry(&ctx).into_iter().map(|(id, _)| id).collect(); + assert!(ids.contains(&"effectStar:0".to_string()), "{ids:?}"); + assert!(!ids.contains(&"effectStar:29".to_string()), "{ids:?}"); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 1ed1071c2..064f931d3 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -217,6 +217,8 @@ pub struct Renderer { pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, + /// Finished jobs thrown away because a newer request for their slot had replaced them. + pub discarded: u64, /// Keep a CPU copy of every texture so the UI can be rasterized headlessly (set when the app /// is driven by the control channel). pub keep_pixels: bool, @@ -297,6 +299,7 @@ impl Renderer { live_hdr_histogram: None, last_main_ms: 0.0, completed: 0, + discarded: 0, keep_pixels: false, stages: HashMap::new(), quick_tried: HashMap::new(), @@ -844,6 +847,7 @@ impl Renderer { && self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown) && r.rendered.is_ok(); if !newer_draft { + self.discarded += 1; continue; } session.accept(&r); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 91e6555b9..032548f6a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -68,8 +68,10 @@ pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> { find_bindable(id).and_then(|b| binding(keymap, id, b.default)) } -/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. -pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; +/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]: +/// the X pair by the Crop panel, A by whether it is open (Visualize Spots everywhere, the aspect +/// lock under the Crop panel). +pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect"), ("view.visualizeSpots", "crop.lockAspect")]; /// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted). pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> { @@ -443,9 +445,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { compare::target_active(app, &mut params); + let was = app.session.active(); let ok = app.run(id, params).is_ok(); if ok && (advance || app.ui.auto_advance) { - compare::advance(app); + compare::advance(app, was); } }; for (id, params) in aliased { @@ -621,6 +624,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { continue; } + // A is both Visualize Spots and the crop aspect lock: each key only in its own context + if f == "view.visualizeSpots" && app.ui.right == crate::state::RightPanel::Crop { + continue; + } + if f == "crop.lockAspect" && app.ui.right != crate::state::RightPanel::Crop { + continue; + } // / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere) if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() { let _ = app.run("spot.refreshSource", json!({})); @@ -631,12 +641,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let _ = app.run("view.maskOverlayColor", json!({})); continue; } - // while cropping: O cycles the guides, Shift+O their orientation, A locks the aspect + // while cropping: O cycles the guides, Shift+O their orientation if app.ui.right == crate::state::RightPanel::Crop { let crop_key = match f.as_str() { "view.maskOverlay" => Some(("view.cropOverlay", json!({}))), "view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))), - "view.visualizeSpots" => Some(("crop.aspect", json!({"aspect": "toggle"}))), _ => None, }; if let Some((cmd, p)) = crop_key { @@ -924,6 +933,38 @@ mod tests { } } + #[test] + fn culling_keys_go_on_from_a_photo_that_leaves_the_filter() { + // #2312: the filter shows one-star photos; taking photo 2's star off makes photo 3 active, once, with or + // without Shift and Auto Advance, and → goes on from there instead of from the first photo + use lightcraft_catalog::PhotoId; + let one_star = || { + let mut h = library(); + h.app.session.execute("library.selectAll", &json!({})).unwrap(); + h.app.session.execute("photo.rate", &json!({"rating": 1})).unwrap(); + h.app.session.execute("library.filter", &json!({"rating": 1, "ratingOp": "exactly"})).unwrap(); + h.app.session.execute("library.select", &json!({"ids": [2]})).unwrap(); + h + }; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::Detail] { + for (shift, auto) in [(false, false), (true, false), (false, true), (true, true)] { + let mut h = one_star(); + h.app.ui.view = view; + h.app.ui.auto_advance = auto; + key(&mut h, "0", shift); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().rating, 0); + assert_eq!(h.app.session.active(), Some(PhotoId(3)), "{view:?} Shift={shift} Auto Advance={auto}"); + key(&mut h, "right", false); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "{view:?} Shift={shift} Auto Advance={auto}"); + } + } + // the Photo menu with Auto Advance too + let mut h = one_star(); + h.app.ui.auto_advance = true; + crate::menubar::run_item(&mut h.app, "photo.rate", json!({"rating": 0})).unwrap(); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); + } + #[test] fn color_label_actions_show_feedback_only_after_success() { use lightcraft_catalog::PhotoId; @@ -1128,7 +1169,8 @@ mod tests { } for (i, (k, a)) in ui.iter().enumerate() { for (k2, b) in &ui[i + 1..] { - assert!(k != k2, "{a} and {b} share a key"); + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); } } for (i, (k, a)) in engine.iter().enumerate() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8cb37d3ef..53d5b8718 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -166,6 +166,18 @@ pub enum StartupView { Detail, } +/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` +/// and `LIGHTCRAFT_LIBRARY` come first either way. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LaunchLibrary { + /// The library open when the app last quit (holding Alt/Option as the window appears asks once). + #[default] + Last, + /// A window lists the recent libraries to choose from. + Ask, +} + /// When grid cells show their rating / flag / edited badges. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -407,6 +419,15 @@ pub struct AppSettings { /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most /// [`RECENT_LIBRARIES`]). pub recent_libraries: Vec, + /// When each recent library was last opened (path → the session clock's ISO 8601 time), for + /// the launch window. A separate key so files from earlier versions, and earlier versions + /// reading this one, keep their list (issue #1290). + #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] + pub recent_opened: std::collections::BTreeMap, + /// Open the last library at launch, or ask (a value this version doesn't know reads as the + /// default). + #[serde(deserialize_with = "lenient")] + pub at_launch: LaunchLibrary, pub startup_view: StartupView, /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved /// `confirmDelete: false` by default (#1239). @@ -415,6 +436,11 @@ pub struct AppSettings { /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask /// again" turns it off). pub ask_before_move: bool, + /// A click on the dimmed backdrop around a dialog closes it, like Esc (off: a stray click + /// never throws away a dialog's choices). + pub close_dialogs_outside: bool, + /// Photos and folders dropped on the window import at once, without the import review. + pub import_dropped_directly: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is @@ -455,7 +481,7 @@ pub struct AppSettings { /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. #[serde(deserialize_with = "lenient")] pub keyword_chips: KeywordChips, - /// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`). + /// View ▸ Focus Point: draw the box the camera focused on (`view.focusPoint`). pub focus_point: bool, /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point /// when the photo has one (`view.zoomToFocus`). @@ -463,6 +489,22 @@ pub struct AppSettings { /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. #[serde(deserialize_with = "lenient")] pub loupe_info: LoupeInfo, + /// File ▸ Import Lightroom Catalog…: ask whether the catalog merges into the open library or + /// gets a new one, or always merge (issue #2672; the window's "Don't ask again" picks that). + #[serde(deserialize_with = "lenient")] + pub lightroom_import: LightroomImport, +} + +/// Where File ▸ Import Lightroom Catalog… puts a catalog (Settings ▸ General ▸ Library). Saved as +/// `"ask"` / `"alwaysMerge"`; anything else in a hand-edited `ui.json` reads as [`Self::Ask`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightroomImport { + /// A window asks each time: merge into the open library, or create a new library for it. + #[default] + Ask, + /// Merge into the open library without asking (how imports worked before the window). + AlwaysMerge, } impl Default for AppSettings { @@ -470,9 +512,13 @@ impl Default for AppSettings { AppSettings { library_path: String::new(), recent_libraries: Vec::new(), + recent_opened: Default::default(), + at_launch: LaunchLibrary::Last, startup_view: StartupView::Last, confirm_delete: true, ask_before_move: true, + close_dialogs_outside: false, + import_dropped_directly: false, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -493,6 +539,7 @@ impl Default for AppSettings { focus_point: false, zoom_to_focus: true, loupe_info: LoupeInfo::default(), + lightroom_import: LightroomImport::Ask, } } } @@ -712,6 +759,10 @@ pub struct UiState { /// a later save succeeds). #[serde(skip)] pub unsaved_seen: bool, + /// Help ▸ Debug ▸ Frame Rate (`view.frameRate`): the frame-rate display + /// (`crate::frame_rate`). A debugging aid, never saved; `LIGHTCRAFT_FPS=1` starts with it on. + #[serde(skip)] + pub frame_rate: bool, pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, @@ -805,6 +856,10 @@ pub struct UiState { /// The last person page left: the People view offers a way back to it next to its title. #[serde(skip)] pub last_person: Option, + /// Assisted Culling's burst settings last confirmed (gap in seconds, similarity), for this + /// session; `None` until the dialog is first confirmed (then 10 s, 0.93). + #[serde(skip)] + pub cull_burst: Option<(f32, f32)>, /// "More" faces the user hid with ×, for this session: (photo, region index). #[serde(skip)] pub dismissed_faces: std::collections::HashSet<(u64, usize)>, @@ -1174,10 +1229,13 @@ pub enum Dialog { /// Help ▸ What's New. WhatsNew, /// Photo ▸ Assisted Culling: reject photos below this focus score (0 = none), pick the best - /// of each burst. + /// of each burst; shots at most `burst_gap` seconds apart and at least `similarity` alike are + /// a burst. Cull { reject_below: f32, pick_best: bool, + burst_gap: f32, + similarity: f32, }, /// Help ▸ System Info: (label, value) rows. DenoiseModel { @@ -1258,6 +1316,29 @@ pub enum Dialog { folder: String, catalog: Option, }, + /// File ▸ Import Lightroom Catalog…: where the catalog at `catalog` goes (issue #2672, see + /// [`crate::lightroom_destination`]). Merge into the open library `library` (`photos` in it), or + /// (`new_library`) create the library `name` in the folder `location`, switch to it and import + /// there. `dont_ask`: merge from now on without asking. `generation`: the library the window + /// was opened for (`Session::library_generation`): another one opened meanwhile isn't merged into. + LightroomDestination { + /// The `.lrcat` file. + catalog: String, + /// The open library's name (its folder's name). + library: String, + /// The photos in the open library. + photos: usize, + /// "Create a new library" is chosen (else "Merge into the open library"). + new_library: bool, + /// The new library's name (prefilled from the catalog's file name). + name: String, + /// The folder the new library goes in (prefilled with the open library's parent folder). + location: String, + /// "Don't ask again (always merge into the open library)". + dont_ask: bool, + /// `Session::library_generation` when the window opened. + generation: u64, + }, /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, /// the colour as `color_sat`), whether it carries Amount and the brush settings. SaveEffect { @@ -1364,6 +1445,7 @@ impl Default for UiState { language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, + frame_rate: std::env::var("LIGHTCRAFT_FPS").is_ok_and(|v| matches!(v.trim(), "1" | "on" | "true" | "yes")), luminance_map_restore: None, view: ViewMode::Detail, left_panel: false, @@ -1410,6 +1492,7 @@ impl Default for UiState { person_page: None, person_from: None, last_person: None, + cull_burst: None, dismissed_faces: Default::default(), unnamed_selected: Default::default(), unnamed_name: String::new(), diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 0473a38fa..e349f694b 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -169,7 +169,7 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { ui.add_space(6.0); let Some(c) = counts.as_ref() else { ui.horizontal(|ui| { - ui.spinner(); + crate::progress::spinner(ui, 14.0); match progress(app) { Some(ScanStage::Listing(folders, files)) => ui.label(listing_text(folders, files)), Some(ScanStage::Checking(done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))), diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index e66c2b7ef..0c305e70f 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -515,7 +515,7 @@ fn rows_added_later_grow_the_stack() { fn immediate_library_commands_and_auto_sync_show_completion() { let mut h = demo(); assert!(h.app.run("library.clearPreviews", json!({})).is_ok()); - assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Clear Preview Cache")); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Discard Preview Cache")); assert_eq!(h.app.run("develop.autoSync", json!({"on": true})).unwrap()["on"], true); assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: On")); diff --git a/crates/ui-egui/src/tests_canvas_pan.rs b/crates/ui-egui/src/tests_canvas_pan.rs new file mode 100644 index 000000000..3bb6ec51e --- /dev/null +++ b/crates/ui-egui/src/tests_canvas_pan.rs @@ -0,0 +1,62 @@ +//! A middle-button drag (the mouse wheel pressed) pans the zoomed photo in Detail whatever tool is +//! active: the healing brush doesn't paint meanwhile, and the hand cursor shows. + +use std::time::Duration; + +use egui::{Event, PointerButton, Pos2, vec2}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn spots(h: &Headless) -> usize { + let id = h.app.session.active().expect("active photo"); + h.app.session.develop_of(id).unwrap_or_default().spots.len() +} + +/// Press `button` at `from`, move to `to` over a few frames and release; the cursor seen mid-drag. +fn drag(h: &mut Headless, button: PointerButton, from: Pos2, to: Pos2) -> egui::CursorIcon { + let modifiers = egui::Modifiers::NONE; + h.app.synthetic.push(Event::PointerMoved(from)); + h.app.synthetic.push(Event::PointerButton { pos: from, button, pressed: true, modifiers }); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + let cursor = h.last_cursor; + h.app.synthetic.push(Event::PointerButton { pos: to, button, pressed: false, modifiers }); + h.step(); + h.step(); + cursor +} + +#[test] +fn a_middle_drag_pans_with_the_healing_brush_and_adds_no_spot() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + for (method, params) in [ + ("ui.set", json!({"view": "detail"})), + ("engine.execute", json!({"command": "panel.remove"})), + ("engine.execute", json!({"command": "view.zoom100"})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + h.step(); + let at = h.app.image_rect.expect("the photo is shown").center(); + let pan = h.app.ui.pan; + let cursor = drag(&mut h, PointerButton::Middle, at, at + vec2(-150.0, -100.0)); + assert_eq!(cursor, egui::CursorIcon::Grabbing, "the hand shows while panning"); + assert_ne!(h.app.ui.pan, pan, "the middle drag pans"); + assert_eq!(spots(&h), 0, "the healing brush doesn't paint during a middle drag"); + // the left button still paints a spot, and doesn't pan + let pan = h.app.ui.pan; + let at = h.app.image_rect.expect("the photo is shown").center(); + drag(&mut h, PointerButton::Primary, at, at + vec2(40.0, 0.0)); + assert_eq!(spots(&h), 1, "a left drag paints a spot"); + assert_eq!(h.app.ui.pan, pan); +} diff --git a/crates/ui-egui/src/tests_capture_time.rs b/crates/ui-egui/src/tests_capture_time.rs new file mode 100644 index 000000000..b5903e053 --- /dev/null +++ b/crates/ui-egui/src/tests_capture_time.rs @@ -0,0 +1,174 @@ +//! Edit Capture Time on a photo whose own capture time doesn't fully read (a Lightroom catalog's +//! `captureTime` is copied verbatim): the preview says so instead of asking for a date the user +//! never typed, and OK leaves the photo as it was and says why. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(60); + +fn set_captured(h: &mut Headless, id: lightcraft_catalog::PhotoId, t: &str) { + h.app.session.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap(); +} + +fn painted(h: &Headless, part: &str) -> bool { + h.painted_text().iter().any(|t| t.contains(part)) +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +/// Select `ids` with `active` and open Edit Capture Time. +fn open(h: &mut Headless, ids: &[lightcraft_catalog::PhotoId], active: lightcraft_catalog::PhotoId) { + let ids: Vec = ids.iter().map(|id| id.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids, "active": active.0}}), T); + let r = h.request("ui.menu.invoke", json!({"id": "dialog.captureTime"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +#[test] +fn shift_preview_says_the_photos_capture_time_cant_be_read() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0]; + set_captured(&mut h, id, "2026-01-01T10:00:00"); + open(&mut h, &[id], id); + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { mode, hours, .. }) => { + *mode = "shift".into(); + *hours = 1; + } + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + assert!(painted(&h, "2026-01-01 10:00:00 → 2026-01-01 11:00:00"), "a readable time previews its shift: {:?}", h.painted_text()); + + let bad = "2026-01-01T10:00:00 and more"; + set_captured(&mut h, id, bad); + h.settle(SETTLE); + assert!(painted(&h, "can't be read"), "the preview says the photo's time can't be read: {:?}", h.painted_text()); + assert!(!painted(&h, "Enter a date"), "no date was typed, so the preview doesn't ask for one: {:?}", h.painted_text()); + + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "OK closes the dialog"); + let toast = toast(&h); + assert!(toast.contains("can't be read"), "OK says the photo was left as it was: {toast:?}"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().captured.as_deref(), Some(bad), "the unreadable time is left as it was"); +} + +#[test] +fn set_mode_refuses_moving_others_by_an_unreadable_time_and_repairs_it_alone() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let ids = h.app.session.visible_cloned(); + let (bad, other) = (ids[0], ids[1]); + let broken = "2026-01-01T10:00:00 and more"; + set_captured(&mut h, bad, broken); + set_captured(&mut h, other, "2026-01-01T12:00:00"); + let open_set = |h: &mut Headless, select: &[lightcraft_catalog::PhotoId]| { + open(h, select, bad); + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { mode, time, .. }) => { + *mode = "set".into(); + *time = "2026-01-01 11:00:00".into(); + } + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + }; + + // with another photo selected, OK can't move it by the unreadable time's change: it says so + // and stays open + open_set(&mut h, &[bad, other]); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let toast = toast(&h); + assert!(toast.contains("can't be read"), "OK says why nothing changed: {toast:?}"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::CaptureTime { .. })), "the dialog stays open after the refusal"); + let captured = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().captured.clone(); + assert_eq!(captured(&h, bad).as_deref(), Some(broken)); + assert_eq!(captured(&h, other).as_deref(), Some("2026-01-01T12:00:00"), "the other photo doesn't move"); + h.request("ui.dialog.cancel", json!({}), T); + + // alone, the preview shows the time it will get, and OK sets it + open_set(&mut h, &[bad]); + let preview = h.painted_text().into_iter().find(|t| t.contains(" → ")).unwrap_or_default(); + assert!(preview.ends_with(" → 2026-01-01 11:00:00"), "the preview shows the time it will get, nothing after: {preview:?}"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "OK closes the dialog"); + assert_eq!(captured(&h, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time typed"); +} + +/// Errors from the command say why and keep the dialog (and what was typed) open, from its OK +/// button and from the control channel alike; they used to close it without a word. +#[test] +fn errors_are_shown_and_the_dialog_stays_open() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0]; + set_captured(&mut h, id, "9999-12-31T12:00:00"); + open(&mut h, &[id], id); + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { time, .. }) => *time = "yesterday".into(), + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); + h.settle(SETTLE); + assert!(toast(&h).contains("not a date"), "OK says the typed time isn't a date: {:?}", toast(&h)); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time == "yesterday"), "the dialog stays open with what was typed"); + + // a shift past year 9999, confirmed through the control channel + match &mut h.app.ui.dialog { + Some(Dialog::CaptureTime { mode, days, .. }) => { + *mode = "shift".into(); + *days = 1; + } + other => panic!("Edit Capture Time is not open: {other:?}"), + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_ne!(r["ok"], true, "the shift is refused: {r}"); + assert!(r.to_string().contains("9999"), "{r}"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::CaptureTime { .. })), "ui.dialog.confirm keeps it open too, as the button does"); + assert_eq!(h.app.session.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00")); +} + +/// A capture time that doesn't fully read isn't offered as the time to set: `2026-01-01T10:0` +/// would read as midnight. The field starts empty and the preview asks for a date. +#[test] +fn set_mode_does_not_start_from_an_unreadable_time() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0]; + set_captured(&mut h, id, "2026-01-01T10:00:00"); + open(&mut h, &[id], id); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time == "2026-01-01 10:00:00"), + "a readable time is offered: {:?}", + h.app.ui.dialog + ); + h.request("ui.dialog.cancel", json!({}), T); + + set_captured(&mut h, id, "2026-01-01T10:0"); + open(&mut h, &[id], id); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::CaptureTime { time, .. }) if time.is_empty()), + "an unreadable time isn't offered: {:?}", + h.app.ui.dialog + ); + assert!(painted(&h, "Enter a date"), "alone, the photo will get the time typed, so the preview asks for one: {:?}", h.painted_text()); +} diff --git a/crates/ui-egui/src/tests_dialog_settings.rs b/crates/ui-egui/src/tests_dialog_settings.rs new file mode 100644 index 000000000..c62bc2f59 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_settings.rs @@ -0,0 +1,374 @@ +//! The Settings window is one size in one place whatever tab is open, and lies inside the app +//! window: a row with more choices than fit wraps, a label longer than its column wraps, a long +//! folder path is cut short (its tooltip has all of it), and the tabs stay put while a page scrolls. +//! +//! Before, a wide row widened the whole window: General was 970 points wide and the other tabs +//! about 600, the window moved up and down between tabs, and in a 900 point wide app window the +//! row labels and the Close button of General were cut off at the left. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::settings::TABS; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// The app window sizes tried: roomy, the desktop minimum, and smaller than that. +const SIZES: [[f32; 2]; 3] = [[1600.0, 1000.0], [900.0, 560.0], [640.0, 480.0]]; + +fn headless(session: lightcraft_engine::Session, size: [f32; 2]) -> Headless { + let mut h = Headless::new(LightcraftApp::new(session, Services::default()), size, 1.0); + // let the first renders finish (a render still running at exit can crash in GPU teardown) + h.settle(T); + h +} + +fn demo(size: [f32; 2]) -> Headless { + headless(lightcraft_engine::Session::with_demo(), size) +} + +/// Given Settings open on `tab`, drawn until its layout has settled. +fn show(h: &mut Headless, tab: &str) { + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T); + assert_eq!(r["ok"], true, "app.settings {tab}: {r}"); + h.settle(T); + for _ in 0..4 { + h.step(); + } +} + +/// A folder under the system's temporary one, removed again when the test ends, passed or not. +struct TempFolder(std::path::PathBuf); + +impl TempFolder { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + Self(dir) + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// The controls the Settings window registers on the tab that is open (and its tab buttons). +fn controls(h: &Headless) -> Vec<(String, egui::Rect)> { + let named = |id: &str| id.split_once(':').is_some_and(|(_, name)| name.starts_with("settings") || name.starts_with("theme")); + h.app.widgets.iter().filter(|(id, _)| named(id)).cloned().collect() +} + +fn same(a: egui::Rect, b: egui::Rect) -> bool { + (a.min - b.min).abs().max_elem() < 0.5 && (a.max - b.max).abs().max_elem() < 0.5 +} + +#[test] +fn the_window_keeps_its_size_and_place_from_tab_to_tab() { + for size in SIZES { + let mut h = demo(size); + // Given the first tab, when every other tab is opened in turn + show(&mut h, "general"); + let first = rect(&h, "dialog:window"); + for (tab, _) in TABS { + show(&mut h, tab); + // Then the window has not moved or changed size + let now = rect(&h, "dialog:window"); + assert!(same(first, now), "{size:?}: Settings is {now:?} on {tab}, and was {first:?} on general"); + } + // And going back to the first tab finds it as it was + show(&mut h, "general"); + assert!(same(first, rect(&h, "dialog:window")), "{size:?}: general again"); + } +} + +#[test] +fn every_tab_keeps_its_controls_inside_the_window() { + for size in SIZES { + let mut h = demo(size); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + for (tab, _) in TABS { + // When a tab is opened + show(&mut h, tab); + // Then the Settings window lies inside the app window + let window = rect(&h, "dialog:window"); + assert!(screen.contains_rect(window), "{size:?} {tab}: Settings {window:?} runs off the {screen:?} window"); + // And no control reaches past its left or right edge + // (the page is everything the tab drew, whether or not it has a name of its own; a + // control past the room for it would be clipped there, inside the window's margin) + let (page, room) = (rect(&h, "settings:page"), rect(&h, "settings:pageRoom")); + assert!( + page.left() >= room.left() - 0.5 && page.right() <= room.right() + 0.5, + "{size:?} {tab}: the page {page:?} is wider than the room for it {room:?}" + ); + assert!(window.contains_rect(room), "{size:?} {tab}: the page's room {room:?} is inside Settings {window:?}"); + for (id, r) in controls(&h) { + let within = if id.contains("settingsTab-") { window } else { room }; + assert!(r.left() >= within.left() - 0.5 && r.right() <= within.right() + 0.5, "{size:?} {tab}: {id} {r:?} is outside {within:?}"); + } + } + } +} + +#[test] +fn a_row_of_many_choices_wraps_under_itself() { + // Given an app window too narrow for the languages in one line + let mut h = demo([900.0, 560.0]); + show(&mut h, "general"); + let languages: Vec = (0..crate::i18n::Locale::ALL.len()).map(|i| rect(&h, &format!("button:settingsLanguage-{i}"))).collect(); + // Then they take more than one line + let first = languages[0]; + assert!(languages.iter().any(|r| r.top() > first.bottom()), "the languages wrap: {languages:?}"); + // And each further line starts under the first choice, not under the row's label + for r in languages.iter().filter(|r| r.top() > first.bottom()) { + assert!(r.left() >= first.left() - 0.5, "{r:?} starts left of the first language {first:?}"); + } + assert!( + languages.iter().filter(|r| r.top() > first.bottom()).any(|r| (r.left() - first.left()).abs() < 0.5), + "a wrapped line starts at the first choice" + ); +} + +#[test] +fn a_label_longer_than_its_column_leaves_the_controls_in_line() { + // Given Performance, whose "Forget unchanged photos" label is wider than the label column + let mut h = demo([1600.0, 1000.0]); + show(&mut h, "performance"); + // Then its choices start where the choices of a short label do + let (short, long) = (rect(&h, "button:settingsPreview-0"), rect(&h, "button:settingsForgetLocal-0")); + assert!((short.left() - long.left()).abs() < 0.5, "Preview size starts at {} and Forget unchanged photos at {}", short.left(), long.left()); +} + +#[test] +fn a_long_library_folder_is_cut_short_and_its_tooltip_has_all_of_it() { + // Given a library in a folder with a very long path + let base = TempFolder::new("settings-window"); + let dir = + base.0.join("photographs-from-2019-to-2026-kept-on-the-second-external-drive-that-nobody-wants-to-type-twice").join("the-master-library"); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&dir, true).unwrap(); + let path = dir.display().to_string(); + + let mut h = headless(session, [1600.0, 1000.0]); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + // When Settings opens on General, which shows where the library is + show(&mut h, "general"); + let general = rect(&h, "dialog:window"); + show(&mut h, "display"); + // Then the window is as wide as on a tab without a path + assert!(same(general, rect(&h, "dialog:window")), "General is {general:?} with a long path, Display is {:?}", rect(&h, "dialog:window")); + show(&mut h, "general"); + assert!(screen.contains_rect(general), "Settings {general:?} runs off the {screen:?} window"); + // And the path is cut short, inside the window + let location = rect(&h, "text:settingsLocation"); + let room = rect(&h, "settings:pageRoom"); + assert!(location.right() <= room.right() + 0.5, "the path {location:?} stays inside the room for the page {room:?}"); + let texts = h.painted_text(); + assert!(!texts.iter().any(|text| text.contains(&path)), "the whole path is not drawn in the row"); + // And what is left of it is its end, which says which library this is + let name = "the-master-library"; + assert!(path.ends_with(name)); + assert!( + texts.iter().any(|text| text.starts_with('…') && text.ends_with(name)), + "the row keeps the end of the path: {:?}", + texts.iter().filter(|t| t.contains("library")).collect::>() + ); + // And resting the pointer on it shows the whole path + assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true); + assert!(h.step_until(T, |h| h.painted_text().iter().any(|text| text.contains(&path))), "the tooltip has the whole path"); + + // And the same holds for the folder Auto Import watches, which stays on one line + let watched = h.app.run("library.autoImport", json!({"folder": path})); + assert!(watched.is_ok(), "{watched:?}"); + show(&mut h, "import"); + // (the row is at the foot of the page: scrolled to, so that it is drawn however long the page is) + let window = rect(&h, "dialog:window"); + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..6 { + h.request("ui.scroll", json!({"dy": -400.0}), T); + h.step(); + } + h.settle(T); + let (folder, room) = (rect(&h, "text:settingsAutoFolder"), rect(&h, "settings:pageRoom")); + assert!(room.intersects(folder), "the watched folder {folder:?} is in view in {room:?}"); + assert!(folder.height() < 24.0 && folder.right() <= room.right(), "the watched folder {folder:?} is one line inside {room:?}"); + assert!(h.painted_text().iter().any(|text| text.starts_with('…') && text.ends_with(name)), "and keeps its end"); + // And Choose… stays on that line, inside the room, whatever length the path is cut to: the + // path takes what the button leaves (the folders' names vary, so the cut falls everywhere) + for n in 0..14 { + let path = format!("{path}/{}/{name}", "d".repeat(2 + 3 * n)); + std::fs::create_dir_all(&path).unwrap(); + assert!(h.app.run("library.autoImport", json!({"folder": path})).is_ok()); + h.settle(T); + for _ in 0..4 { + h.step(); + } + let (folder, button) = (rect(&h, "text:settingsAutoFolder"), rect(&h, "button:settingsAutoFolder")); + assert!(button.right() <= room.right() + 0.5, "Choose… {button:?} is inside {room:?} (path of {n})"); + assert!((button.center().y - folder.center().y).abs() < 6.0, "Choose… {button:?} is on the line of the path {folder:?} (path of {n})"); + assert!(folder.right() <= button.left(), "the path {folder:?} ends before Choose… {button:?} (path of {n})"); + } + drop(h); +} + +#[test] +fn library_default_stays_on_the_line_of_the_smart_previews_folder_whatever_its_length() { + // Given libraries whose folder names grow in steps (the smart previews folder is `Smart Previews` in + // each, so its path is cut at a different place every time, and some cut leaves less room than the note needs) + let base = TempFolder::new("settings-smart"); + for n in 0..24 { + let dir = base.0.join(format!("photographs-kept-on-the-second-external-drive-{n}")).join("m".repeat(2 + 3 * n)); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&dir, true).unwrap(); + let mut h = headless(session, [1600.0, 1000.0]); + // When Performance is scrolled to the foot of its page + show(&mut h, "performance"); + let window = rect(&h, "dialog:window"); + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..6 { + h.request("ui.scroll", json!({"dy": -400.0}), T); + h.step(); + } + h.settle(T); + // Then "(library default)" stays inside the room, on the line of the path, after it + let room = rect(&h, "settings:pageRoom"); + let (smart, note) = (rect(&h, "text:settingsSmartFolder"), rect(&h, "text:settingsSmartDefault")); + assert!(room.intersects(smart) && room.intersects(note), "the smart previews folder {smart:?} {note:?} is in view in {room:?} (name of {n})"); + assert!(note.right() <= room.right() + 0.5, "(library default) {note:?} is inside {room:?} (name of {n})"); + assert!((note.center().y - smart.center().y).abs() < 6.0, "(library default) {note:?} is on the line of the path {smart:?} (name of {n})"); + assert!(smart.right() <= note.left(), "the path {smart:?} ends before (library default) {note:?} (name of {n})"); + drop(h); + } +} + +#[test] +fn a_short_path_is_shown_whole_and_has_no_tooltip() { + // Given the in-memory library, whose Location is a short sentence + let mut h = demo([1600.0, 1000.0]); + show(&mut h, "general"); + assert!(h.painted_text().iter().any(|text| text == "In memory — nothing is saved"), "it is all there"); + let before = h.painted_text().len(); + // When the pointer rests on it + assert_eq!(h.request("ui.hoverWidget", json!({"id": "text:settingsLocation"}), T)["ok"], true); + let _ = h.step_until(Duration::from_secs(2), |h| h.painted_text().len() > before); + // Then nothing more is drawn: there is nothing a tooltip could add + assert_eq!(h.painted_text().len(), before, "no tooltip for a path that is all there"); +} + +/// How many pixels of `rect` are not `colour` (one pixel per point here). +fn other_than(image: &egui::ColorImage, rect: egui::Rect, colour: egui::Color32) -> usize { + let [w, h] = image.size; + let (x0, x1) = (rect.left().round().max(0.0) as usize, (rect.right().round().max(0.0) as usize).min(w)); + let (y0, y1) = (rect.top().round().max(0.0) as usize, (rect.bottom().round().max(0.0) as usize).min(h)); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|(x, y)| image.pixels[y * w + x] != colour).count() +} + +#[test] +fn a_page_that_goes_on_below_shows_a_scroll_handle_without_the_pointer_on_it() { + let mut h = demo([900.0, 560.0]); + let chrome = crate::theme::Tokens::get(&h.view.ctx).chrome; + // (the pointer is nowhere near the window) + h.request("ui.move", json!({"x": 5.0, "y": 5.0}), T); + let handle = |h: &Headless| { + let room = rect(h, "settings:pageRoom"); + other_than(&h.paint(), egui::Rect::from_min_max(egui::pos2(room.right() - 6.0, room.top()), room.max), chrome) + }; + // Given Import, which is taller than the room a 560 pt tall app window has for it + show(&mut h, "import"); + assert!(rect(&h, "settings:page").height() > rect(&h, "settings:pageRoom").height()); + // Then a handle is drawn at the page's right edge + assert!(handle(&h) > 40, "Import shows a scroll handle ({} px)", handle(&h)); + // And Display, which fits, has none + show(&mut h, "display"); + assert!(rect(&h, "settings:page").height() <= rect(&h, "settings:pageRoom").height()); + assert_eq!(handle(&h), 0, "Display has nothing to scroll"); +} + +#[test] +fn a_window_too_short_for_a_page_of_its_own_scrolls_tabs_and_page_together() { + // Given an app window far below the desktop minimum + let mut h = demo([900.0, 220.0]); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + show(&mut h, "import"); + let window = rect(&h, "dialog:window"); + assert!(screen.contains_rect(window), "Settings {window:?} is inside the {screen:?} window"); + let (tab, page) = (rect(&h, "button:settingsTab-import"), rect(&h, "check:settings.perCamera")); + // When it is scrolled + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..3 { + h.request("ui.scroll", json!({"dy": -120.0}), T); + h.step(); + } + h.settle(T); + // Then the tabs and the page move as one, so every setting can still be reached + let (tab_now, page_now) = (rect(&h, "button:settingsTab-import"), rect(&h, "check:settings.perCamera")); + assert!(page_now.top() < page.top() - 50.0, "the page scrolled: {page:?} -> {page_now:?}"); + assert!(((tab.top() - tab_now.top()) - (page.top() - page_now.top())).abs() < 0.5, "tabs and page moved together"); + assert!(same(window, rect(&h, "dialog:window")), "the window did not move"); +} + +#[test] +fn the_tabs_stay_put_while_a_page_scrolls() { + // Given a page taller than the window has room for + let mut h = demo([900.0, 560.0]); + show(&mut h, "import"); + let window = rect(&h, "dialog:window"); + let tabs: Vec = TABS.iter().map(|(id, _)| rect(&h, &format!("button:settingsTab-{id}"))).collect(); + let page_before = rect(&h, "check:settings.perCamera"); + // When the page is scrolled + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..3 { + h.request("ui.scroll", json!({"dy": -120.0}), T); + h.step(); + } + h.settle(T); + // Then the page moved + let page_after = rect(&h, "check:settings.perCamera"); + assert!(page_after.top() < page_before.top() - 50.0, "the page scrolled: {page_before:?} -> {page_after:?}"); + // And the tabs and the window did not + for ((id, _), before) in TABS.iter().zip(tabs) { + assert!(same(before, rect(&h, &format!("button:settingsTab-{id}"))), "tab {id} moved"); + } + assert!(same(window, rect(&h, "dialog:window")), "the window moved"); +} + +#[test] +fn each_tab_remembers_how_far_it_was_scrolled() { + let mut h = demo([900.0, 560.0]); + show(&mut h, "interface"); + let fresh = rect(&h, "button:settingsAppearance-0"); + // Given Import scrolled down + show(&mut h, "import"); + let window = rect(&h, "dialog:window"); + let top = rect(&h, "check:settings.perCamera").top(); + h.request("ui.move", json!({"x": window.center().x, "y": window.center().y}), T); + for _ in 0..3 { + h.request("ui.scroll", json!({"dy": -120.0}), T); + h.step(); + } + h.settle(T); + let scrolled = rect(&h, "check:settings.perCamera").top(); + assert!(scrolled < top - 50.0); + // When another tab is opened, it starts at its top + show(&mut h, "interface"); + let mode = rect(&h, "button:settingsAppearance-0"); + assert!(same(fresh, mode), "Interface is at its top, as when first opened: {fresh:?} -> {mode:?}"); + // And Import is where it was left + show(&mut h, "import"); + assert!((rect(&h, "check:settings.perCamera").top() - scrolled).abs() < 0.5, "Import is where it was left"); + // And it is still there when Settings is closed and opened again (documented in What's New) + assert_eq!(h.request("ui.dialog.cancel", json!({}), T)["ok"], true); + h.settle(T); + show(&mut h, "import"); + assert!((rect(&h, "check:settings.perCamera").top() - scrolled).abs() < 0.5, "Import is where it was left after a reopen"); +} diff --git a/crates/ui-egui/src/tests_dialogs.rs b/crates/ui-egui/src/tests_dialogs.rs index 6581640ec..2e96b59e7 100644 --- a/crates/ui-egui/src/tests_dialogs.rs +++ b/crates/ui-egui/src/tests_dialogs.rs @@ -85,6 +85,71 @@ fn modal_dialogs_block_background_shortcuts_and_keep_escape() { assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores shortcuts"); } +/// Settings → Close a dialog by clicking outside it: a click on the backdrop closes the dialog +/// like Esc, without reaching the panels below; a click inside it, or a drag that ends outside, keeps it. +#[test] +fn a_click_outside_closes_the_dialog_when_settings_allow_it() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + let active = h.app.session.active().unwrap(); + let rating = h.app.session.catalog.photo(active).unwrap().rating; + h.app.run("dialog.copySettings", json!({})).unwrap(); + h.step(); + // off by default: the background click is blocked and the dialog stays + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_some(), "a stray click keeps the dialog unless Settings say otherwise"); + h.app.ui.settings.close_dialogs_outside = true; + let r = h.request("ui.clickWidget", json!({"id": "copyGroup:none"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_some(), "a click inside the dialog keeps it"); + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").map(|(_, r)| *r).unwrap(); + let r = h.request("ui.drag", json!({"x": window.center().x, "y": window.center().y, "toX": 10.0, "toY": 10.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_some(), "a drag that ends outside the dialog keeps it"); + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "a click on the backdrop closes the dialog"); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating, "the click doesn't reach the panel below"); + // the click that opens a dialog lands outside its window; a quick one on a slow frame arrives + // pressed and released in that one frame + let gear = h.app.widgets.iter().find(|(id, _)| id == "icon:copyGear").map(|(_, r)| r.center()).unwrap(); + let press = |pressed| egui::Event::PointerButton { pos: gear, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(gear), press(true), press(false)]); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_some(), "the click that opens a dialog doesn't close it"); + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "the next click on the backdrop does"); +} + +/// With a dropdown open in the dialog, a click on the backdrop only closes the dropdown, even when +/// it arrives pressed and released in one frame (the dropdown closes while the dialog is drawn). +#[test] +fn a_click_outside_an_open_dropdown_keeps_the_dialog() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + h.app.ui.settings.close_dialogs_outside = true; + h.app.run("dialog.export", json!({})).unwrap(); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "combo:exportPreset"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx), "the preset dropdown is open"); + let at = egui::pos2(10.0, 990.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(at), press(true), press(false)]); + h.step(); + h.step(); + assert!(!egui::Popup::is_any_open(&h.view.ctx), "the click closes the dropdown"); + assert!(h.app.ui.dialog.is_some(), "but not the dialog"); +} + #[test] fn modal_dialogs_close_background_menus() { let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); diff --git a/crates/ui-egui/src/tests_drop_import.rs b/crates/ui-egui/src/tests_drop_import.rs new file mode 100644 index 000000000..837d60c76 --- /dev/null +++ b/crates/ui-egui/src/tests_drop_import.rs @@ -0,0 +1,331 @@ +//! Files dragged from the file manager onto the window: while they hover, the window says what +//! dropping will do; dropping photos or folders opens the import review (unless Settings ▸ Import +//! says to import dropped files directly); a drop while a dialog is open is refused. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ui-drop-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write_png(path: &str, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// An empty library, a folder `trip` with two photos, a window big enough for the overlay. +fn app(dir: &Scratch) -> Headless { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/b.png"), 2); + let session = lightcraft_engine::Session::new().with_fs(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 900.0], 1.0); + for _ in 0..4 { + h.step(); + } + h +} + +fn drop_files(h: &mut Headless, paths: &[String], hover: bool) { + let r = h.request("ui.dropFiles", json!({"paths": paths, "hover": hover}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..3 { + h.step(); + } +} + +fn overlay(h: &Headless) -> bool { + h.app.widgets.iter().any(|(w, _)| w == "dropOverlay") +} + +fn said(h: &Headless, word: &str) -> bool { + h.painted_text().iter().any(|t| t.contains(word)) +} + +fn photos(h: &Headless) -> usize { + h.app.session.catalog.photos().count() +} + +#[test] +fn hovering_photos_says_they_will_be_reviewed_and_lets_go_when_they_leave() { + let dir = Scratch::new("hover"); + let mut h = app(&dir); + assert!(!overlay(&h), "nothing is dragged over the window"); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], true); + assert!(overlay(&h), "the window shows where to drop"); + assert!(said(&h, "review 2 items"), "{:?}", h.painted_text()); + drop_files(&mut h, &[], true); + assert!(!overlay(&h), "the files left the window"); +} + +#[test] +fn hovering_presets_says_they_will_be_imported_as_presets() { + let dir = Scratch::new("hover-presets"); + let mut h = app(&dir); + std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); + drop_files(&mut h, &[dir.path("warm.lcpreset")], true); + assert!(overlay(&h)); + assert!(said(&h, "1 preset"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "presets are not reviewed"); +} + +#[test] +fn hovering_unknown_files_says_only_that_dropping_imports() { + let dir = Scratch::new("hover-unknown"); + let mut h = app(&dir); + drop_files(&mut h, &[String::new()], true); + assert!(overlay(&h)); + assert!(said(&h, "Drop to import"), "{:?}", h.painted_text()); +} + +#[test] +fn dropping_a_folder_opens_the_import_review_and_imports_nothing_yet() { + let dir = Scratch::new("review"); + let mut h = app(&dir); + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 2); + assert_eq!(photos(&h), 0, "nothing is imported until the review is confirmed"); + assert!(!overlay(&h), "the files were let go"); +} + +#[test] +fn dropping_imports_directly_when_settings_say_so() { + let dir = Scratch::new("direct"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.step_until(SETTLE, |h| photos(h) == 2 && h.app.import.is_none()), "both photos arrive"); + assert!(h.app.ui.dialog.is_none(), "no review"); +} + +#[test] +fn a_drop_while_a_dialog_is_open_leaves_the_dialog_and_imports_nothing() { + let dir = Scratch::new("dialog"); + let mut h = app(&dir); + h.app.ui.dialog = Some(Dialog::WhatsNew); + drop_files(&mut h, &[dir.path("trip/a.png")], true); + assert!(overlay(&h), "the window still answers the hover"); + assert!(said(&h, "Close the open dialog"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("trip")], false); + for _ in 0..30 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the open dialog stays"); + assert!(h.app.scan.is_none() && h.app.import.is_none(), "no scan or import started"); + assert_eq!(photos(&h), 0); + assert!(h.app.ui.toast.is_some(), "the person is told why"); +} + +/// The scan of a dropped folder finishing while another dialog is open (the face model's licence, Settings…) +/// keeps that dialog; the review opens, with the scan's photos, once it is closed. +#[test] +fn a_finished_scan_waits_for_the_open_dialog_before_opening_the_review() { + let dir = Scratch::new("waits"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.dialog, Some(Dialog::WhatsNew)), "the dialog the person had open stays"); + h.app.ui.dialog = None; + assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::Import { .. }))), "the review opens once the dialog is closed"); + let Some(Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert_eq!(opts.candidates.len(), 2, "the scan's photos were kept"); +} + +/// A scan that finishes behind Settings, which then opens another library: the review of the first +/// library's scan doesn't open over the second one once Settings closes, and nothing is imported into it. +#[test] +fn a_waiting_review_is_dropped_when_another_library_opens() { + let dir = Scratch::new("switch"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::Settings { tab: "general".into() }); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + let stale = h.app.review_pending.clone().expect("the review waits behind Settings"); + h.app.run("app.openLibrary", json!({"path": dir.path("other")})).unwrap(); + h.app.ui.dialog = None; + for _ in 0..10 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review from the other library opens"); + assert!(h.app.review_pending.is_none()); + // a review confirmed after the switch (an agent holding it) imports nothing either + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &stale).is_err()); + for _ in 0..10 { + h.step(); + } + assert!(h.app.import.is_none(), "nothing is imported"); + assert_eq!(photos(&h), 0); +} + +/// A review waiting behind another dialog counts as an import in progress: a direct drop meanwhile is +/// refused, and the review's OK never replaces an import that runs when it is confirmed. +#[test] +fn a_waiting_review_never_replaces_a_running_import() { + let dir = Scratch::new("pending-running"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + write_png(&dir.path("more/c.png"), 3); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::WhatsNew); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + assert!(h.app.review_pending.is_some(), "the review waits behind the dialog"); + assert!(crate::import::start_paths(&mut h.app, vec![dir.path("more")]).is_err(), "no second import starts"); + assert!(h.app.import.is_none()); + h.app.ui.dialog = None; + h.step(); + let Some(review) = h.app.ui.dialog.clone() else { panic!("the review opens once the dialog is closed") }; + assert!(matches!(review, Dialog::Import { .. })); + // an import that started anyway (a Local folder browse, say) is the one that runs + h.app.import = Some(crate::import::ImportTask::new(vec![dir.path("more")], json!({"mode": "add"}), 0, false)); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &review).is_err(), "the review's OK is refused"); + assert_eq!(h.app.import.as_ref().map(|t| t.total), Some(1), "the running import stays"); + assert!(h.step_until(SETTLE, |h| h.app.import.is_none()), "it runs to the end"); + assert_eq!(photos(&h), 1, "only the running import's photo arrives"); +} + +/// A drop while an import runs would open a review whose OK replaces the running import: it is +/// refused instead, and the card says so. +#[test] +fn a_drop_while_an_import_runs_is_refused() { + let dir = Scratch::new("running"); + let mut h = app(&dir); + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan(&mut h.app); + drop_files(&mut h, &[dir.path("more/c.png")], true); + assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("more")], false); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review opened"); + assert!(h.app.scan.is_some() && h.app.import.is_none(), "the running scan was left alone and nothing else started"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + assert_eq!(photos(&h), 0); +} + +/// An import that is running stays the one that runs: a direct drop doesn't start a second. +#[test] +fn a_direct_drop_while_an_import_runs_is_refused_too() { + let dir = Scratch::new("running-direct"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = true; + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan(&mut h.app); + drop_files(&mut h, &[dir.path("more")], false); + assert!(h.app.import.is_none(), "no second import"); + assert!(h.app.ui.toast.is_some()); +} + +/// A Local folder browse scan counts too: a review-first drop would be rejected by `open`, and a +/// direct drop would be replaced when the browse completes. +#[test] +fn a_drop_while_a_browse_scan_runs_is_refused_in_both_modes() { + for direct in [false, true] { + let dir = Scratch::new("running-browse"); + let mut h = app(&dir); + h.app.ui.settings.import_dropped_directly = direct; + write_png(&dir.path("more/c.png"), 3); + let _scan = crate::import::stall_scan_of(&mut h.app, true); + drop_files(&mut h, &[dir.path("more/c.png")], true); + assert!(said(&h, "An import is running"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("more")], false); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "direct={direct}: nothing started"); + assert!(h.app.scan.is_some(), "the browse was left alone"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + } +} + +/// While a Lightroom catalog import is pending the drop is refused, and the card says so instead of +/// promising a review. +#[test] +fn hovering_photos_while_a_lightroom_import_is_pending_says_they_will_be_refused() { + let dir = Scratch::new("lightroom"); + let mut h = app(&dir); + let _task = crate::lightroom_import::stall_import(&mut h.app); + drop_files(&mut h, &[dir.path("trip")], true); + assert!(said(&h, "Finish Lightroom catalog import"), "{:?}", h.painted_text()); + assert!(!said(&h, "review"), "{:?}", h.painted_text()); + drop_files(&mut h, &[dir.path("trip")], false); + assert!(h.app.ui.dialog.is_none(), "no review opened"); + assert!(h.app.ui.toast.is_some(), "the person is told why"); + assert_eq!(photos(&h), 0); +} + +/// A script is not told `ok` for the part of a `paths` list that was strings. +#[test] +fn drop_files_with_a_non_string_path_is_rejected_and_drops_nothing() { + let dir = Scratch::new("badpaths"); + let mut h = app(&dir); + let r = h.request("ui.dropFiles", json!({"paths": [dir.path("trip"), 7]}), T); + assert_eq!(r["ok"], false, "{r}"); + for _ in 0..30 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "nothing was dropped"); + assert_eq!(photos(&h), 0); +} + +/// No library open yet (the window of a library that failed to open, or the launch list): the +/// card says the photos wait, and a drop keeps them for the library chosen instead of importing +/// them into the session that library replaces. +#[test] +fn photos_dropped_with_no_library_open_wait_for_one() { + let dir = Scratch::new("no-library"); + let mut h = app(&dir); + h.app.library_problem = Some(crate::panels::library_problem::LibraryProblem::new(dir.path("Gone"), "not found")); + std::fs::write(dir.path("warm.lcpreset"), "{}").unwrap(); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("warm.lcpreset")], true); + assert!(said(&h, "once a library is open"), "{:?}", h.painted_text()); + assert!(said(&h, "drop the presets again") && !said(&h, "1 preset"), "{:?}", h.painted_text()); + drop_files(&mut h, &[], true); + drop_files(&mut h, &[dir.path("trip/a.png"), dir.path("trip/b.png")], false); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.len()), Some(2)); + assert!(h.app.ui.dialog.is_none() && h.app.import.is_none(), "no review, no import into the temporary session"); + assert_eq!(photos(&h), 0); + // a preset belongs to a library: refused, not imported into the session the library replaces + h.app.ui.toast = None; + drop_files(&mut h, &[dir.path("warm.lcpreset")], false); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("drop the presets again"), "{toast}"); +} diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs index f93dd4f7b..efd7f5716 100644 --- a/crates/ui-egui/src/tests_focus_point.rs +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -1,4 +1,4 @@ -//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the +//! Headless tests of the camera's focus point (#1480): View ▸ Focus Point draws the box where the //! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel //! names the focus mode. diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs index f6903550c..cd1f190cf 100644 --- a/crates/ui-egui/src/tests_grading.rs +++ b/crates/ui-egui/src/tests_grading.rs @@ -375,3 +375,125 @@ fn wheel_rim_is_fully_saturated_and_the_centre_neutral() { let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b())); assert!(hi - lo <= 40 && lo >= 100, "centre is not a neutral grey: {p:?}"); } + +// ------------------------------------------------------------------ the held pointer (issue #1899) + +fn pointer_event(h: &mut Headless, e: egui::Event) { + h.events.push(e); + h.step(); +} + +fn button(h: &mut Headless, at: egui::Pos2, pressed: bool) { + let e = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + pointer_event(h, e); +} + +/// Press the midtones wheel at `at` and move the pointer 10 to the right, far enough to make it +/// a drag; where the pointer is then. +fn start_wheel_drag(h: &mut Headless, at: egui::Pos2) -> egui::Pos2 { + pointer_event(h, egui::Event::PointerMoved(at)); + button(h, at, true); + let to = at + egui::vec2(10.0, 0.0); + pointer_event(h, egui::Event::PointerMoved(to)); + h.step(); + to +} + +fn cursor_moves(h: &Headless) -> Vec { + h.window_commands + .iter() + .filter_map(|c| match c { + egui::ViewportCommand::CursorPosition(p) => Some(*p), + _ => None, + }) + .collect() +} + +#[test] +fn a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits() { + use crate::panels::edit::wheel_pick; + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + let c = widget(&h, "wheel:midtones").unwrap().center(); + let held = start_wheel_drag(&mut h, c + egui::vec2(rad * 0.4, 0.0)); + // the pointer stays where it is (macOS locks it there) while the mouse goes on moving up + for _ in 0..5 { + pointer_event(&mut h, egui::Event::MouseMoved(egui::vec2(0.0, -rad * 0.1))); + } + let (hue, sat) = wheel_value(&h, "midtones"); + let (want_hue, want_sat) = wheel_pick(held + egui::vec2(0.0, -rad * 0.5) - c, rad, None); + assert!((hue - want_hue.round() as f64).abs() <= 1.0 && (sat - want_sat.round() as f64).abs() <= 1.0, "{hue} {sat} vs {want_hue} {want_sat}"); + assert!(hue > 30.0, "the puck went up with the mouse: {hue}"); + button(&mut h, held, false); +} + +#[test] +fn a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, rad * 0.2); + let at = start_wheel_drag(&mut h, press); + let far = at + egui::vec2(-rad * 0.6, -rad * 0.9); + pointer_event(&mut h, egui::Event::PointerMoved(far)); + assert_eq!(h.last_cursor, egui::CursorIcon::None, "hidden while the wheel is dragged"); + assert!(cursor_moves(&h).is_empty(), "nothing moves the pointer before the release"); + button(&mut h, far, false); + h.step(); + assert_eq!(cursor_moves(&h), [press], "back where the button went down"); + assert_ne!(h.last_cursor, egui::CursorIcon::None, "shown again"); + #[cfg(target_os = "macos")] + assert_eq!( + h.window_commands.iter().filter(|c| matches!(c, egui::ViewportCommand::CursorGrab(_))).collect::>(), + [&egui::ViewportCommand::CursorGrab(egui::CursorGrab::Locked), &egui::ViewportCommand::CursorGrab(egui::CursorGrab::None)], + "locked for the drag, unlocked at the end" + ); + // a click is not a drag: the pointer stays visible where it is + h.window_commands.clear(); + let r = h.request("ui.clickWidget", json!({"id": "wheel:midtones"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +#[test] +fn escape_or_losing_focus_gives_the_pointer_back_mid_drag() { + let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for let_go in [esc, egui::Event::WindowFocused(false)] { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, 0.0); + let at = start_wheel_drag(&mut h, press); + assert_eq!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}: hidden while dragged"); + pointer_event(&mut h, let_go.clone()); + h.step(); + assert_ne!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}: shown again"); + assert_eq!(cursor_moves(&h), [press], "{let_go:?}: back where it was pressed"); + // the rest of the drag no longer moves the puck, and the pointer stays visible + let before = wheel_value(&h, "midtones"); + pointer_event(&mut h, egui::Event::PointerMoved(at + egui::vec2(-rad * 0.5, -rad * 0.5))); + assert_eq!(wheel_value(&h, "midtones"), before, "{let_go:?}"); + assert_ne!(h.last_cursor, egui::CursorIcon::None, "{let_go:?}"); + button(&mut h, at, false); + h.step(); + assert_eq!(cursor_moves(&h), [press], "{let_go:?}: given back once"); + } +} + +#[test] +fn a_wheel_that_goes_away_mid_drag_gives_the_pointer_back() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + let press = widget(&h, "wheel:midtones").unwrap().center() + egui::vec2(rad * 0.3, 0.0); + start_wheel_drag(&mut h, press); + assert_eq!(h.last_cursor, egui::CursorIcon::None); + // the flyout closes under the drag: no wheel is drawn to let go of the pointer itself + h.app.ui.toggle_flyout("grading"); + h.step(); + h.step(); + assert!(widget(&h, "wheel:midtones").is_none()); + assert_ne!(h.last_cursor, egui::CursorIcon::None); + assert_eq!(cursor_moves(&h), [press]); +} diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 92997d941..b48da7cdf 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -453,3 +453,51 @@ fn cmd_o_does_not_fire_while_a_dialog_is_open() { press(&mut h, "O", &["cmd"]); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); } + +/// The crop aspect lock is a first-class row of Help - Keyboard Shortcuts: A, listed in the +/// editor like any other command, sharing the key with Visualize Spots as a contextual pair. +#[test] +fn lock_aspect_ratio_is_a_listed_row_on_a() { + use crate::shortcuts::{CONTEXTUAL, Keymap, bindable, shortcut_of}; + let b = bindable().iter().find(|b| b.id == "crop.lockAspect").expect("crop.lockAspect is mappable"); + assert_eq!(b.label, "Lock Aspect Ratio"); + assert_eq!(b.default, Some("A")); + assert_eq!(shortcut_of(&Keymap::new(), "crop.lockAspect"), Some("A")); + assert!(CONTEXTUAL.contains(&("view.visualizeSpots", "crop.lockAspect")), "A is disambiguated by context"); + // the editor lists it: the row is on screen and records keys for it + let mut h = demo(); + record(&mut h, "lock", "crop.lockAspect"); + key(&mut h, "Escape", false); + assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "crop.lockAspect"), Some("A"), "Esc cancels the recording: A stays"); + key(&mut h, "Escape", false); + assert!(h.app.ui.dialog.is_none()); +} + +/// A is the aspect lock while the Crop panel is open — Visualize Spots, the other A, must not take +/// the panel or turn its view on — and outside the panel A opens Visualize Spots as usual. The lock +/// is the tool's own (a fresh photo starts locked to its shape and the toggle may only record +/// "free", leaving the raw develop aspect untouched), so the test reads `crop_tool_aspect`. +#[test] +fn a_is_the_aspect_lock_under_crop_and_visualize_spots_elsewhere() { + use crate::state::RightPanel; + let mut h = demo(); + let aspect = |h: &Headless| h.app.crop_tool_aspect(); + let spots_before = h.app.ui.visualize_spots; + h.app.run("panel.crop", json!({})).unwrap(); + h.step(); + h.step(); + assert_eq!(h.app.ui.right, RightPanel::Crop); + let before = aspect(&h); + key(&mut h, "A", false); + assert_eq!(h.app.ui.right, RightPanel::Crop, "Visualize Spots must not take the panel"); + assert_eq!(h.app.ui.visualize_spots, spots_before, "nor turn its view on"); + assert_ne!(aspect(&h), before, "A toggles the aspect lock"); + key(&mut h, "A", false); + assert_eq!(aspect(&h), before, "A toggles it back"); + // outside the panel the same key is Visualize Spots again + h.app.ui.right = RightPanel::None; + key(&mut h, "A", false); + assert_eq!(h.app.ui.right, RightPanel::Remove, "A opens Remove with the visualization"); + assert!(h.app.ui.visualize_spots, "Visualize Spots is on"); + assert_eq!(aspect(&h), before, "and the aspect is untouched"); +} diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 95b02967c..076f25789 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -196,6 +196,38 @@ fn the_active_photo_alone_is_the_selection() { assert!(has(&h, "keywordCheck:beach")); } +/// Given a selection with a keyword that is inside others, when the Keyword List is shown, then +/// every keyword above it carries a dot in its box (also folded, where the keyword itself is out +/// of sight), and its own box is the one that is ticked. A keyword above it that the selection +/// has itself shows its tick and no dot; a keyword with nothing applied inside shows neither. +#[test] +fn a_keyword_shows_that_one_inside_it_is_applied() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["travel|Italy|Rome"]}})); + h.settle(SETTLE); + // folded: `travel` is the only row of the three, and says there is something inside + assert!(has(&h, "keywordRow:travel") && !has(&h, "keywordRow:travel|Italy"), "folded"); + assert!(has(&h, "keywordInside:travel")); + assert!(!has(&h, "keywordInside:beach") && !has(&h, "keywordInside:Weddings"), "nothing is inside these"); + // the dot sits in the keyword's own box + let rect = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap(); + assert!(rect(&h, "keywordCheck:travel").contains_rect(rect(&h, "keywordInside:travel"))); + // opened down to it: each level above has the dot, the keyword itself the tick + h.app.ui.keyword_list_open.extend(["travel".to_string(), "travel|italy".to_string()]); + h.settle(SETTLE); + assert!(has(&h, "keywordInside:travel") && has(&h, "keywordInside:travel|Italy")); + assert!(has(&h, "keywordRow:travel|Italy|Rome") && !has(&h, "keywordInside:travel|Italy|Rome")); + // a keyword the selection has itself shows its own state: the tick, or the dash when only some photos have it + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "addKeywords": ["travel"]}})); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "addKeywords": ["travel|Italy"]}})); + h.settle(SETTLE); + assert!(!has(&h, "keywordInside:travel") && !has(&h, "keywordInside:travel|Italy")); + // with nothing selected nothing is applied, inside or not + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": []}})); + h.settle(SETTLE); + assert!(has(&h, "keywordRow:travel") && !h.app.widgets.iter().any(|(w, _)| w.starts_with("keywordInside:"))); +} + /// Esc in the Add keyword box gives back what it held before this edit, as every text field does: /// text left there by an earlier edit stays. #[test] diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests_launch_library.rs new file mode 100644 index 000000000..b0b3bec6b --- /dev/null +++ b/crates/ui-egui/src/tests_launch_library.rs @@ -0,0 +1,991 @@ +//! Issue #1290: what opens at launch. Settings ▸ General ▸ At launch opens the last library or +//! asks with a window listing the recent ones (greyed out with the reason when they can't be +//! opened); Alt/Option held as the window appears asks once; one recent entry can be forgotten from +//! File ▸ Open Recent Library, the window and `app.recentLibraries {remove}`. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::library_problem::{ALT_SECONDS, Launch, LibraryProblem, launch_plan}; +use crate::state::{AppSettings, LaunchLibrary}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// A fresh scratch folder for one test. +fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-launch-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A library on disk at `dir` (written, then closed: as left by an earlier run). +fn make_library(dir: &Path) -> String { + let mut s = lightcraft_engine::Session::new().with_fs(); + s.open_library(dir, false).unwrap(); + s.close_library().unwrap(); + drop(s); + assert!(lightcraft_engine::library::is_library(dir)); + dir.to_string_lossy().into_owned() +} + +/// Enough frames for a synthetic click (move, press, release) and what it starts. +fn settle(h: &mut Headless) { + for _ in 0..6 { + h.step(); + } +} + +/// The widget `id` was drawn in the last frame. +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// `Some(s)` as a path, for the launch plan's arguments. +fn path(s: &str) -> Option { + Some(PathBuf::from(s)) +} + +/// `--library`, then `LIGHTCRAFT_LIBRARY`, then the setting; `--memory` never asks. +#[test] +fn the_launch_plan_puts_the_flag_and_the_variable_before_the_setting() { + let ask = AppSettings { at_launch: LaunchLibrary::Ask, library_path: "/last".into(), ..AppSettings::default() }; + let last = AppSettings { library_path: "/last".into(), recent_libraries: vec!["/last".into(), "/older".into()], ..AppSettings::default() }; + let fresh = AppSettings::default(); + let open = |dir: &str, alt_asks| Launch::Open { dir: path(dir), alt_asks }; + // --library, then LIGHTCRAFT_LIBRARY, whatever the setting says; neither asks on Alt + for s in [None, Some(&ask), Some(&last)] { + assert_eq!(launch_plan(false, path("/flag"), path("/env"), s, path("/default")), open("/flag", false)); + assert_eq!(launch_plan(false, None, path("/env"), s, path("/default")), open("/env", false)); + } + // Ask me asks, with or without a recent list + assert_eq!(launch_plan(false, None, None, Some(&ask), path("/default")), Launch::Ask); + let ask_empty = AppSettings { at_launch: LaunchLibrary::Ask, ..AppSettings::default() }; + assert_eq!(launch_plan(false, None, None, Some(&ask_empty), None), Launch::Ask); + // Open the last library: the last one, else the default place; Alt can still ask + assert_eq!(launch_plan(false, None, None, Some(&last), path("/default")), open("/last", true)); + assert_eq!(launch_plan(false, None, None, Some(&fresh), path("/default")), open("/default", true)); + assert_eq!(launch_plan(false, None, None, None, path("/default")), open("/default", true)); + assert_eq!(launch_plan(false, None, None, None, None), Launch::Open { dir: None, alt_asks: true }); + // --memory never asks and opens no folder + assert_eq!(launch_plan(true, None, None, Some(&ask), path("/default")), Launch::Open { dir: None, alt_asks: false }); + assert_eq!(launch_plan(true, path("/flag"), None, Some(&last), None), Launch::Open { dir: None, alt_asks: false }); +} + +/// Each listed library is checked when the window opens; the last one opened is selected. +#[test] +fn the_list_checks_each_library_and_selects_the_last_one() { + let dir = temp("choose"); + let (a, b, held, plain) = (dir.join("A"), dir.join("B"), dir.join("Held"), dir.join("Plain")); + let (a, b, held_path) = (make_library(&a), make_library(&b), make_library(&held)); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("notes.txt"), b"not a library").unwrap(); + let file = dir.join("a-file.txt"); + std::fs::write(&file, b"a file").unwrap(); + let (gone, plain, file) = + (dir.join("Gone").to_string_lossy().into_owned(), plain.to_string_lossy().into_owned(), file.to_string_lossy().into_owned()); + // another program has it open: listing doesn't take locks, so it is found when opened + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); + + let mut settings = AppSettings { + recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), file.clone(), b.clone()], + library_path: b.clone(), + ..AppSettings::default() + }; + settings.recent_opened.insert(b.clone(), "2026-10-20T08:30:00".into()); + let p = LibraryProblem::choose(&settings); + assert!(!p.saves_nothing(), "the launch list is not a temporary session: removals are saved"); + let c = p.chooser.as_ref().unwrap(); + let why: Vec> = c.libraries.iter().map(|l| l.unavailable.as_deref()).collect(); + assert!(why[0].unwrap().contains("Not found"), "{why:?}"); + assert_eq!(why[1], None, "a lock is not probed: {why:?}"); + assert_eq!(why[2], None); + assert!(why[3].unwrap().contains("no LightCraft library"), "{why:?}"); + assert!(why[4].unwrap().contains("not a library folder"), "{why:?}"); + assert_eq!(why[5], None); + assert_eq!(c.selected, Some(5), "the last library opened"); + assert_eq!(c.libraries[5].opened.as_deref(), Some("2026-10-20T08:30:00")); + assert_eq!(c.libraries[2].opened, None, "no time known"); + let json = p.to_json(); + assert_eq!(json["chooser"]["libraries"][0]["available"], false); + assert_eq!(json["chooser"]["selected"], 5); + + // the last library can't be opened: the first one that can is selected + settings.library_path = plain.clone(); + assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(1)); + // none can: the first row, Open greyed out + settings.recent_libraries = vec![gone.clone(), file.clone()]; + assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(0)); + // an empty list selects nothing + settings.recent_libraries.clear(); + let c = LibraryProblem::choose(&settings).chooser.unwrap(); + assert!(c.libraries.is_empty() && c.selected.is_none()); + assert!(LibraryProblem::new("/x", "e").saves_nothing(), "the failed-library window still saves nothing"); + drop(lock); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Every file under `dir`: relative path → (contents, modification time). +fn snapshot(dir: &Path) -> std::collections::BTreeMap, std::time::SystemTime)> { + let mut out = std::collections::BTreeMap::new(); + let mut todo = vec![dir.to_path_buf()]; + while let Some(d) = todo.pop() { + for e in std::fs::read_dir(&d).unwrap() { + let p = e.unwrap().path(); + let m = std::fs::metadata(&p).unwrap(); + if m.is_dir() { + todo.push(p.clone()); + } + let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() }; + out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap())); + } + } + out +} + +/// Listing the recent libraries (and drawing the window) writes nothing into any of them: no +/// lock file, no owner file, nothing modified — free, held by another program or not a library. +#[test] +fn listing_the_libraries_leaves_every_folder_byte_identical() { + let dir = temp("readonly"); + let (free, fresh, held) = (dir.join("Free"), dir.join("Fresh"), dir.join("Held")); + let (free, held_path) = (make_library(&free), make_library(&held)); + // a library whose lock files were never made (copied over from elsewhere, say) + let fresh_path = make_library(&fresh); + for f in [lightcraft_catalog::lock::LOCK, lightcraft_catalog::lock::OWNER] { + let _ = std::fs::remove_file(fresh.join(f)); + } + std::fs::create_dir_all(dir.join("Plain")).unwrap(); + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); + let recent = vec![free.clone(), fresh_path.clone(), held_path.clone(), dir.join("Plain").to_string_lossy().into_owned()]; + let before = snapshot(&dir); + // (mtime granularity: a write in the same instant would still change contents or add files) + let settings = AppSettings { recent_libraries: recent.clone(), library_path: free.clone(), ..AppSettings::default() }; + let p = LibraryProblem::choose(&settings); + assert_eq!(snapshot(&dir), before, "checking the rows must not create or change any file"); + assert!(p.chooser.as_ref().unwrap().libraries.iter().take(3).all(|l| l.unavailable.is_none())); + let mut h = asking(recent, &free, None); + settle(&mut h); + assert!(has(&h, "libraryRecent:2")); + drop(h); + assert_eq!(snapshot(&dir), before, "listing must not create or change any file"); + assert!(!fresh.join(lightcraft_catalog::lock::LOCK).exists() && !fresh.join(lightcraft_catalog::lock::OWNER).exists()); + drop(lock); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The app as the desktop host starts it with *Ask me*: no library open, the list showing. +fn asking(recent: Vec, last: &str, picked: Option) -> Headless { + let session = lightcraft_engine::Session::new().with_fs(); + // (an encoder for the screenshots LIGHTCRAFT_TEST_SHOTS asks for) + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + let services = Services { + png: Some(Box::new(png)), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = LightcraftApp::new(session, services); + app.ui.settings.recent_libraries = recent; + app.ui.settings.library_path = last.to_string(); + app.ui.settings.at_launch = LaunchLibrary::Ask; + if let Some(p) = picked { + app.services.pick_folder = Some(Box::new(move || Some(p.clone()))); + } + app.library_problem = Some(LibraryProblem::choose(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + h +} + +/// The window lists every recent library, greys out a missing one and opens the chosen one. +#[test] +fn the_window_lists_the_libraries_and_opens_the_chosen_one() { + let dir = temp("window"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut h = asking(vec![a.clone(), gone.clone(), b.clone()], &a, None); + assert!(h.app.session.library.is_none(), "nothing opened before the choice"); + for id in ["libraryRecent:0", "libraryRecent:1", "libraryRecent:2", "button:libraryOpen", "button:libraryOpenAnother", "button:libraryNew"] { + assert!(has(&h, id), "{id}"); + } + assert!(has(&h, "button:libraryQuit") && has(&h, "button:libraryForget")); + assert!(!has(&h, "button:libraryRetry"), "not the failed-library window"); + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": Path::new(&p).join("launch-list.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let inspect = h.request("ui.inspect", json!({}), T); + let c = &inspect["result"]["libraryProblem"]["chooser"]; + assert_eq!(c["selected"], 0, "{c}"); + assert_eq!(c["libraries"][1]["available"], false, "{c}"); + + // ↓ onto the missing one: Return does nothing there (Open is greyed out) + assert_eq!(h.request("ui.key", json!({"key": "ArrowDown"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert!(h.app.session.library.is_none() && h.app.library_problem.is_some()); + // ↓ again, then Open: Beta opens and the window goes away + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(b.clone())); + assert!(h.app.library_problem.is_none()); + assert!(!has(&h, "button:libraryOpen")); + assert_eq!(h.app.ui.settings.library_path, b); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Return opens the selection; a library taken meanwhile stays listed with the reason. +#[test] +fn return_opens_the_selected_library_and_a_failure_says_why() { + let dir = temp("return"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let mut h = asking(vec![a.clone(), b.clone()], &b, None); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + // another program takes Beta after the window opened: Open says so and keeps the window + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&b), "another LightCraft").unwrap(); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert!(h.app.session.library.is_none()); + let p = h.app.library_problem.as_ref().unwrap(); + assert!(p.error.contains("already open in another LightCraft"), "who has it: {}", p.error); + assert!(p.chooser.as_ref().unwrap().libraries[1].unavailable.is_some(), "greyed out now"); + drop(lock); + // ↑ to Alpha, Return: it opens + h.request("ui.key", json!({"key": "ArrowUp"}), T); + settle(&mut h); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(a)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Quit and Esc quit without opening anything. +#[test] +fn quit_and_esc_quit_from_the_window() { + let dir = temp("quit"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, None); + h.request("ui.key", json!({"key": "Escape"}), T); + settle(&mut h); + assert!(h.quit_requested(), "Esc quits when no library is open"); + assert!(h.app.session.library.is_none(), "and opens nothing"); + drop(h); + + let mut h = asking(vec![a.clone()], &a, None); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryQuit"}), T)["ok"], true); + settle(&mut h); + assert!(h.quit_requested()); + assert!(h.app.session.library.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Open Another… runs the folder dialog of `app.openLibrary`, also with an empty list. +#[test] +fn open_another_asks_for_a_folder_and_an_empty_list_still_offers_it() { + let dir = temp("another"); + let other = make_library(&dir.join("Elsewhere")); + let mut h = asking(Vec::new(), "", Some(other.clone())); + assert!(has(&h, "indicator:noRecentLibraries")); + assert!(!has(&h, "libraryRecent:0")); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(other.clone())); + assert!(h.app.library_problem.is_none()); + assert_eq!(h.app.ui.settings.recent_libraries.first(), Some(&other), "remembered once opened"); + drop(h); + + // without a folder dialog (the browser) Open Another… and New Library… are greyed out + let mut h = asking(Vec::new(), "", None); + h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T); + h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + settle(&mut h); + assert!(h.app.library_problem.is_some() && h.app.ui.dialog.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// New Library… asks for a name in front of the window; command-line photos go into it. +#[test] +fn new_library_from_the_window_asks_for_a_name_and_the_list_returns_if_cancelled() { + let dir = temp("newlib"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, Some(dir.to_string_lossy().into_owned())); + // a photo given on the command line waits for the library chosen + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let photo = dir.join("a.png"); + std::fs::write(&photo, png).unwrap(); + h.app.library_problem.as_mut().unwrap().pending_import = vec![photo.to_string_lossy().into_owned()]; + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T)["ok"], true); + settle(&mut h); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::TextPrompt { .. })), "{:?}", h.app.ui.dialog); + assert!(!has(&h, "button:libraryOpen"), "the name prompt is in front"); + h.request("ui.dialog.cancel", json!({}), T); + settle(&mut h); + assert!(has(&h, "button:libraryOpen"), "the list is back"); + // what the prompt's OK runs: the library is made where the folder dialog says, and opens + h.app.run("app.newLibrary", json!({"name": "Fresh"})).unwrap(); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(dir.join("Fresh"))); + assert!(h.app.library_problem.is_none()); + h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); + assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the new library"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Remove from List in the window forgets the selected entry and deletes nothing. +#[test] +fn remove_from_list_forgets_one_library_and_deletes_nothing() { + let dir = temp("forget"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut h = asking(vec![a.clone(), gone.clone(), b.clone()], &a, None); + h.app.ui.settings.recent_opened.insert(gone.clone(), "2026-01-01T00:00:00".into()); + // the window: the missing one can be selected and removed + h.request("ui.clickWidget", json!({"id": "libraryRecent:1"}), T); + settle(&mut h); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryForget"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.ui.settings.recent_libraries, [a.clone(), b.clone()]); + assert!(!h.app.ui.settings.recent_opened.contains_key(&gone), "its time goes too"); + let c = h.app.library_problem.as_ref().unwrap().chooser.clone().unwrap(); + assert_eq!(c.libraries.len(), 2); + assert_eq!(c.selected, Some(1)); + // removing an existing library leaves its folder and catalog alone + h.request("ui.clickWidget", json!({"id": "button:libraryForget"}), T); + settle(&mut h); + assert_eq!(h.app.ui.settings.recent_libraries, vec![a.clone()]); + assert!(lightcraft_engine::library::is_library(Path::new(&b)), "nothing is deleted on disk"); + settle(&mut h); + assert!(!has(&h, "libraryRecent:1")); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Open Recent Library's items. +fn open_recent(app: &LightcraftApp) -> Vec { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + match file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Open Recent Library")) { + Some(MenuNode::Submenu { children, .. }) => children.clone(), + _ => panic!("no Open Recent Library submenu in {file:?}"), + } +} + +/// File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}`. +#[test] +fn open_recent_library_and_the_command_remove_one_entry() { + use crate::menubar::MenuNode; + let dir = temp("menu"); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + let (first, gone) = (dir.join("first").to_string_lossy().into_owned(), dir.join("gone").to_string_lossy().into_owned()); + app.ui.settings.recent_libraries = vec![first.clone(), gone.clone(), "/elsewhere/third".into()]; + + let items = open_recent(&app); + let Some(MenuNode::Submenu { children, .. }) = items.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Remove from List")) + else { + panic!("no Remove from List in {items:?}") + }; + assert_eq!(children.len(), 3, "every entry, missing ones too"); + let MenuNode::Item { id, params, label, enabled, .. } = &children[1] else { panic!("{children:?}") }; + assert_eq!((id.as_str(), params, *enabled), ("app.recentLibraries", &json!({"remove": gone}), true)); + assert!(label.starts_with("gone — "), "{label}"); + assert_eq!(crate::menubar::display_item_label(id, params, label), label, "a path is never translated"); + // what clicking it runs + let list = app.run(id, params.clone()).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert_eq!(app.ui.settings.recent_libraries, [first.clone(), "/elsewhere/third".to_string()]); + + // the command: the open library can be forgotten too (it stays open), and nothing is deleted + let list = app.run("app.recentLibraries", json!({"remove": first})).unwrap(); + // (no frame ran: nothing checked yet, so `exists` is not known; the command never looks itself) + assert_eq!(list, json!([{"path": "/elsewhere/third", "name": "third", "exists": null, "checking": false, "open": false, "lastOpened": null}])); + assert!(app.session.library.is_some() && lightcraft_engine::library::is_library(&dir.join("first"))); + let e = app.run("app.recentLibraries", json!({"remove": first})).unwrap_err(); + assert!(e.contains("not in the recent libraries"), "{e}"); + let e = app.run("app.recentLibraries", json!({"remove": 3})).unwrap_err(); + assert!(e.contains("remove must be"), "{e}"); + app.run("app.recentLibraries", json!({"remove": "/elsewhere/third"})).unwrap(); + // an empty list has nothing to remove + let items = open_recent(&app); + assert!(!items.iter().any(|n| matches!(n, MenuNode::Submenu { .. })), "{items:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `atLaunch` and `recentLibrariesOpened` round-trip, and older or odd files still load. +#[test] +fn the_setting_and_the_open_times_are_kept_in_the_settings_file() { + // files from earlier versions: no key, the default; a value from a later version: the default + let old: crate::UiState = serde_json::from_str(r#"{"settings": {"recentLibraries": ["/a"], "startupView": "detail"}}"#).unwrap(); + assert_eq!(old.settings.at_launch, LaunchLibrary::Last); + assert!(old.settings.recent_opened.is_empty()); + assert_eq!(old.settings.recent_libraries, ["/a"]); + let odd: crate::UiState = + serde_json::from_str(r#"{"settings": {"atLaunch": "someday", "recentLibrariesOpened": 7, "startupView": "detail"}}"#).unwrap(); + assert_eq!(odd.settings.at_launch, LaunchLibrary::Last); + assert!(odd.settings.recent_opened.is_empty()); + assert_eq!(odd.settings.startup_view, crate::state::StartupView::Detail, "the rest of the file still loads"); + + let mut ui = crate::UiState::default(); + ui.settings.at_launch = LaunchLibrary::Ask; + ui.settings.recent_libraries = vec!["/a".into()]; + ui.settings.recent_opened.insert("/a".into(), "2026-10-23T09:00:00".into()); + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["settings"]["atLaunch"], "ask"); + assert_eq!(saved["settings"]["recentLibraries"], json!(["/a"]), "the list keeps its old shape"); + assert_eq!(saved["settings"]["recentLibrariesOpened"], json!({"/a": "2026-10-23T09:00:00"})); + let back: crate::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.settings, ui.settings); +} + +/// The Settings choice and `ui.set` change the setting; an opened library gets its time. +#[test] +fn settings_choose_ask_me_and_opening_a_library_records_when() { + let dir = temp("settings"); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + app.services.pick_folder = Some(Box::new(|| None)); + app.ui.settings.recent_opened.insert("/forgotten".into(), "2020-01-01T00:00:00".into()); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + let first = dir.join("first").to_string_lossy().into_owned(); + // the session clock's time (tests: a fixed one), only for listed libraries + assert_eq!(h.app.ui.settings.recent_opened.get(&first).map(String::as_str), Some("2026-09-30T12:00:00")); + assert!(!h.app.ui.settings.recent_opened.contains_key("/forgotten")); + + h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), T); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAtLaunch-1"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Ask); + h.request("ui.clickWidget", json!({"id": "button:settingsAtLaunch-0"}), T); + settle(&mut h); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Last); + // also through ui.set, as scripts change settings + let r = h.request("ui.set", json!({"settings": {"atLaunch": "ask"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Ask); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The app as the host starts it with *Open the last library*: `first` open, Alt watched. +fn opened_last(dir: &Path) -> (Headless, String, String) { + let (first, second) = (make_library(&dir.join("first")), make_library(&dir.join("second"))); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&first, false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + app.ui.settings.recent_libraries = vec![first.clone(), second.clone()]; + app.ui.settings.library_path = first.clone(); + app.alt_asks_for_library = true; + (Headless::new(app, [1300.0, 900.0], 1.0), first, second) +} + +/// Alt held as the window appears shows the list once over the open library; Esc keeps it. +#[test] +fn alt_held_as_the_window_appears_asks_once_over_the_last_library() { + let dir = temp("alt"); + let (mut h, first, _) = opened_last(&dir); + settle(&mut h); + assert!(h.app.library_problem.is_none(), "no Alt: the last library, no window"); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).expect("the list shows"); + assert_eq!(c.selected, Some(0)); + assert!(c.over.is_some(), "over the library that opened"); + assert!(c.libraries[0].unavailable.is_none(), "the open library itself can be chosen"); + assert!(has(&h, "button:libraryOpen")); + // Esc keeps the library that opened + h.request("ui.key", json!({"key": "Escape"}), T); + settle(&mut h); + assert!(!h.quit_requested(), "Esc doesn't quit while a library is open"); + assert!(h.app.library_problem.is_none()); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(first.clone())); + // once only + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + assert!(h.app.library_problem.is_none()); + drop(h); + + // choosing another library from it switches to that one + let dir2 = temp("alt-switch"); + let (mut h, _, second2) = opened_last(&dir2); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(second2)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} + +/// The Alt watch ends after the first seconds and needs the host to arm it. +#[test] +fn alt_is_only_watched_while_the_window_first_appears_and_when_armed() { + let frame = |app: &mut LightcraftApp, ctx: &egui::Context, time: f64, alt: bool| { + let modifiers = if alt { egui::Modifiers::ALT } else { egui::Modifiers::NONE }; + let raw = egui::RawInput { time: Some(time), events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }; + ctx.run_ui(raw, |ui| crate::panels::library_problem::logic(app, ui.ctx())).textures_delta.clear(); + }; + let new_app = |armed| { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + app.alt_asks_for_library = armed; + app + }; + // too late: the watch ends, a later Alt does nothing + let (mut app, ctx) = (new_app(true), egui::Context::default()); + frame(&mut app, &ctx, ALT_SECONDS + 0.5, false); + assert!(!app.alt_asks_for_library); + frame(&mut app, &ctx, ALT_SECONDS + 0.6, true); + assert!(app.library_problem.is_none()); + // not armed (--library, LIGHTCRAFT_LIBRARY, --memory, the browser) + let (mut app, ctx) = (new_app(false), egui::Context::default()); + frame(&mut app, &ctx, 0.1, true); + assert!(app.library_problem.is_none()); + // armed, in time + let (mut app, ctx) = (new_app(true), egui::Context::default()); + frame(&mut app, &ctx, 0.1, false); + assert!(app.alt_asks_for_library && app.library_problem.is_none()); + frame(&mut app, &ctx, 0.2, true); + assert!(app.library_problem.as_ref().is_some_and(|p| p.chooser.is_some())); + assert!(!app.alt_asks_for_library); +} + +/// A check that waits until the test lets it go (a network share whose server is gone), then +/// answers like the real one for a missing folder. `release` lets every waiting check go. +fn slow_checks() -> ( + crate::panels::library_problem::RecentChecks, + std::sync::Arc<(std::sync::Mutex, std::sync::Condvar)>, + std::sync::Arc, +) { + let gate = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (g, n) = (gate.clone(), calls.clone()); + let checks = crate::panels::library_problem::RecentChecks::with_check(move |p| { + n.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (lock, cv) = &*g; + let open = lock.lock().unwrap(); + // (bounded: a test that forgets to release still ends) + let _unused = cv.wait_timeout_while(open, Duration::from_secs(10), |open| !*open).unwrap(); + (!Path::new(p).exists()).then_some(crate::panels::library_problem::Unavailable::NotFound) + }); + (checks, gate, calls) +} + +/// Let every check waiting in [`slow_checks`] answer. +fn release(gate: &std::sync::Arc<(std::sync::Mutex, std::sync::Condvar)>) { + let (lock, cv) = &**gate; + *lock.lock().unwrap() = true; + cv.notify_all(); +} + +/// Review of #2701: the launch list never waits on a folder. Its rows show "Checking…" while a +/// worker looks (the activity stack lists it), the window keeps drawing frames meanwhile, and the +/// answers grey out a missing library and move the selection to one that can be opened. +#[test] +fn a_slow_check_never_holds_the_launch_window() { + let dir = temp("slow"); + let a = make_library(&dir.join("A")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![gone.clone(), a.clone()]; + app.ui.settings.library_path = gone.clone(); + let (checks, gate, _) = slow_checks(); + app.recent_checks = checks; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t0 = std::time::Instant::now(); + settle(&mut h); + assert!(t0.elapsed() < Duration::from_secs(5), "frames went on while the check waited: {:?}", t0.elapsed()); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries.iter().all(|l| l.checking && l.unavailable.is_none()), "{c:?}"); + assert_eq!(c.selected, Some(0), "the last library opened, until the answers say otherwise"); + assert!(h.painted_text().iter().any(|t| t == "Checking…"), "{:?}", h.painted_text()); + assert!(h.app.session.activity.list().iter().any(|t| t.label == "Checking recent libraries"), "the activity stack lists the check"); + + release(&gate); + let done = + h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking))); + assert!(done, "the answers came back"); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].unavailable.as_deref().is_some_and(|w| w.contains("Not found")), "{c:?}"); + assert_eq!(c.libraries[1].unavailable, None); + assert_eq!(c.selected, Some(1), "the first library that can be opened, once known"); + assert!(h.app.session.activity.list().iter().all(|t| t.label != "Checking recent libraries"), "the row goes with the check"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Answers that arrive after the person moved the highlight leave it where they put it. +#[test] +fn late_answers_keep_the_row_the_person_chose() { + let dir = temp("touched"); + let a = make_library(&dir.join("A")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![a.clone(), gone.clone()]; + app.ui.settings.library_path = a.clone(); + let (checks, gate, _) = slow_checks(); + app.recent_checks = checks; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + release(&gate); + h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking))); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert_eq!(c.selected, Some(1), "still the row moved to, though it can't be opened"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Open Recent Library is rebuilt every frame: it never looks at a folder itself. A +/// library is greyed out only once a worker found it missing, and each path is checked once. +#[test] +fn the_recent_menu_takes_its_answers_from_a_worker() { + use crate::menubar::MenuNode; + let dir = temp("menu-worker"); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![gone.clone()]; + let (checks, gate, calls) = slow_checks(); + app.recent_checks = checks; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let enabled = |h: &Headless| match open_recent(&h.app).first() { + Some(MenuNode::Item { enabled, .. }) => *enabled, + other => panic!("{other:?}"), + }; + for _ in 0..30 { + h.step(); + assert!(enabled(&h), "not known yet: kept enabled (opening says why if it can't)"); + } + release(&gate); + assert!(h.step_until(T, |h| !h.app.recent_checks.is_running() && h.app.recent_checks.is_known(&gone)), "answered"); + for _ in 0..30 { + h.step(); + } + assert!(!enabled(&h), "found missing: greyed out"); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "checked once, not every frame"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The reasons are put into words on the UI thread, in the interface language (the worker that +/// found them has none of its own). +#[test] +fn the_reasons_are_in_the_interface_language() { + let dir = temp("lang"); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.language = crate::i18n::Locale::Fr; + app.ui.settings.recent_libraries = vec![gone.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + assert!( + h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking))) + ); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + let why = c.libraries[0].unavailable.clone().unwrap_or_default(); + let english = "Not found: moved, renamed or on a disconnected drive"; + crate::i18n::set_language(crate::i18n::Locale::Fr); + assert_eq!(why, crate::i18n::tr(english), "translated on the UI thread"); + assert_ne!(why, english, "the French catalog has this reason"); + crate::i18n::set_language(crate::i18n::Locale::En); + let _ = std::fs::remove_dir_all(&dir); +} + +/// One drive that never answers doesn't hold the others: each path has its own worker, and the +/// row that waits says the drive isn't answering once it takes long. +#[test] +fn one_hung_drive_doesnt_hold_the_other_libraries() { + let dir = temp("hung"); + let a = make_library(&dir.join("A")); + let hung = dir.join("Hung").to_string_lossy().into_owned(); + let gate = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let g = gate.clone(); + let h_path = hung.clone(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(move |p| { + if p == h_path { + let (lock, cv) = &*g; + let open = lock.lock().unwrap(); + let _unused = cv.wait_timeout_while(open, Duration::from_secs(10), |open| !*open).unwrap(); + } + None + }); + app.ui.settings.recent_libraries = vec![hung.clone(), a.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let a_done = h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| !c.libraries[1].checking)); + assert!(a_done, "the reachable library answered while the other one hangs"); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].checking, "the hung one is still checking"); + release(&gate); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A check whose worker dies (a panic) ends as "couldn't be checked" instead of leaving the row, +/// and every later check, waiting forever. +#[test] +fn a_check_that_dies_doesnt_leave_the_list_waiting() { + let dir = temp("dies"); + let a = make_library(&dir.join("A")); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(|_| panic!("a check that dies")); + app.ui.settings.recent_libraries = vec![a.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let done = h.step_until(T, |h| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| !c.libraries[0].checking)); + assert!(done, "the row stopped waiting"); + assert!(!h.app.recent_checks.is_running(), "no check is left running"); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].unavailable.as_deref().is_some_and(|w| w.contains("couldn't be checked")), "{c:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A library found missing comes back once it is opened again (a drive plugged back in): the +/// menu no longer greys it out. +#[test] +fn opening_a_library_clears_an_old_missing_answer() { + use crate::menubar::MenuNode; + let dir = temp("back"); + let a = make_library(&dir.join("A")); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![a.clone()]; + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(|_| Some(crate::panels::library_problem::Unavailable::NotFound)); + let ctx = egui::Context::default(); + crate::panels::library_problem::logic(&mut app, &ctx); + assert!(crate::tasks::wait(&mut app, &ctx, T), "checked"); + assert_eq!(app.recent_checks.exists(&a), Some(false), "found missing (the drive was disconnected)"); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.app.run("app.openLibrary", json!({"path": a})).unwrap(); + for _ in 0..4 { + h.step(); + } + assert!(matches!(open_recent(&h.app).first(), Some(MenuNode::Item { enabled: true, .. })), "{:?}", open_recent(&h.app)); + assert_eq!(h.app.recent_checks.reason(&a), None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review of #2701: a library that fails to open from the launch window because another program +/// has it is there all the same. The row shows the open's error, but the menu keeps it enabled and +/// `app.recentLibraries` says it exists; an unreadable catalog doesn't say the library is gone +/// either (`exists: null`). +#[test] +fn a_failed_open_or_an_unreadable_catalog_doesnt_mark_a_library_missing() { + use crate::menubar::MenuNode; + let dir = temp("held"); + let held = make_library(&dir.join("Held")); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![held.clone()]; + app.ui.settings.library_path = held.clone(); + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let answered = + |h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking)); + assert!(h.step_until(T, answered)); + assert_eq!(h.app.recent_checks.exists(&held), Some(true)); + // another program has it: Open from the launch window fails + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held), "another LightCraft").unwrap(); + settle(&mut h); + let r = h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T); + assert_eq!(r["ok"], true, "{r} {:?}", h.app.widgets.iter().map(|(w, _)| w.clone()).filter(|w| w.contains("library")).collect::>()); + settle(&mut h); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).unwrap(); + assert!(c.libraries[0].unavailable.is_some(), "the row shows why it didn't open: {c:?}"); + assert!(h.step_until(T, |h| !h.app.recent_checks.is_running() && h.app.recent_checks.is_known(&held)), "checked again"); + assert_eq!(h.app.recent_checks.exists(&held), Some(true), "a failed open doesn't mark it missing"); + let list = h.app.run("app.recentLibraries", json!({})).unwrap(); + assert_eq!(list[0]["exists"], true, "{list}"); + assert!(matches!(open_recent(&h.app).first(), Some(MenuNode::Item { enabled: true, .. })), "{:?}", open_recent(&h.app)); + drop(lock); + + // an unreadable catalog: not known to be gone + h.app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(|_| { + Some(crate::panels::library_problem::Unavailable::CatalogUnreadable("denied".into())) + }); + assert!(h.step_until(T, |h| h.app.recent_checks.is_known(&held) && !h.app.recent_checks.is_running())); + assert_eq!(h.app.run("app.recentLibraries", json!({})).unwrap()[0]["exists"], serde_json::Value::Null); + assert!(matches!(open_recent(&h.app).first(), Some(MenuNode::Item { enabled: true, .. }))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Photos dropped on the launch list wait for the library chosen (CodeRabbit on #2689): before, +/// they went into the session the library then replaced, and were gone from view. +#[test] +fn photos_dropped_on_the_launch_list_go_into_the_library_chosen() { + let dir = temp("drop"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, None); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let photo = dir.join("a.png"); + std::fs::write(&photo, png).unwrap(); + // frames with nothing dropped (the drop code runs every frame) hold nothing and say nothing + settle(&mut h); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); + let ctx = h.view.ctx.clone(); + assert_eq!(crate::panels::library_problem::hold_dropped(&mut h.app, &ctx, Vec::new()), Some(Vec::new())); + assert!(h.app.ui.toast.is_none(), "an empty drop says nothing: {:?}", h.app.ui.toast); + let photos = vec![photo.to_string_lossy().into_owned()]; + assert!(crate::panels::library_problem::holds_drops(&h.app)); + assert_eq!(crate::panels::library_problem::hold_dropped(&mut h.app, &ctx, photos.clone()), None, "held while no library is open"); + assert_eq!(h.app.library_problem.as_ref().map(|p| p.pending_import.clone()), Some(photos.clone())); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("once a library is open")), "{:?}", h.app.ui.toast); + assert_eq!(h.app.session.catalog.len(), 0, "nothing goes into the session the library replaces"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T)["ok"], true); + settle(&mut h); + h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); + assert_eq!(h.app.session.catalog.len(), 1, "the dropped photo is imported into the library opened"); + // with a library open, a drop is the usual import + assert!(!crate::panels::library_problem::holds_drops(&h.app)); + assert_eq!(crate::panels::library_problem::hold_dropped(&mut h.app, &ctx, photos.clone()), Some(photos)); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A lookup that fails without saying the library is gone (here a parent folder it may not read; +/// a network share that timed out is the same) is "couldn't check", not "not found": the entry +/// isn't greyed out in File ▸ Open Recent Library (CodeRabbit on #2689). +#[cfg(unix)] +#[test] +fn a_failed_lookup_doesnt_mark_a_recent_library_missing() { + use std::os::unix::fs::PermissionsExt; + /// Gives the folder its permissions back and removes the test's folder, also when an assertion + /// fails (a folder left at mode 000 can't be removed). + struct Cleanup(std::path::PathBuf, std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::set_permissions(&self.1, std::fs::Permissions::from_mode(0o755)); + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = temp("lookup"); + let locked = dir.join("Locked"); + let _cleanup = Cleanup(dir.clone(), locked.clone()); + let inside = make_library(&locked.join("Inside")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read_dir(&locked).is_ok() { + // running as root (some CI containers): the mode bits don't stop it, so no lookup fails + lightcraft_testkit::skip!(System, "a folder with no permissions can still be read (running as root)"); + return; + } + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.settings.recent_libraries = vec![inside.clone(), gone.clone()]; + let mut h = Headless::new(app, [900.0, 600.0], 1.0); + let answered = h.step_until(T, |h| h.app.recent_checks.is_known(&inside) && h.app.recent_checks.is_known(&gone)); + assert!(answered); + assert_eq!(h.app.recent_checks.exists(&gone), Some(false), "a library that isn't there is missing"); + assert_eq!(h.app.recent_checks.exists(&inside), None, "one that couldn't be looked at isn't"); + let why = h.app.recent_checks.reason(&inside).map(|w| w.text()).unwrap_or_default(); + assert!(why.starts_with("This library couldn't be checked:"), "{why}"); + // nor is a folder that is there but refuses to be listed (permissions, macOS privacy) + use crate::panels::library_problem::Unavailable; + assert!(!Unavailable::Unreadable("denied".into()).is_missing() && !Unavailable::LookupFailed("timed out".into()).is_missing()); + assert!(Unavailable::NotFound.is_missing()); + drop(h); +} + +/// A library found is asked about again when the window comes back to the front, not on a timer: a +/// timer would wake every external drive and network share in the list over and over. One not +/// found is looked for again on its own (a drive plugged back in with the app in front). +#[test] +fn recent_libraries_are_checked_again_only_when_the_window_comes_back() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let dir = temp("recheck"); + let a = make_library(&dir.join("A")); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let (calls, gone_calls) = (std::sync::Arc::new(AtomicUsize::new(0)), std::sync::Arc::new(AtomicUsize::new(0))); + let (c, g, gone_path) = (calls.clone(), gone_calls.clone(), gone.clone()); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(move |p| { + if p == gone_path { + g.fetch_add(1, Ordering::SeqCst); + return Some(crate::panels::library_problem::Unavailable::NotFound); + } + c.fetch_add(1, Ordering::SeqCst); + None + }) + .recheck_after(Duration::ZERO); + app.ui.settings.recent_libraries = vec![a.clone(), gone.clone()]; + let mut h = Headless::new(app, [900.0, 600.0], 1.0); + assert!(h.step_until(T, |h| h.app.recent_checks.exists(&a) == Some(true))); + assert!(h.step_until(T, |_| gone_calls.load(Ordering::SeqCst) >= 3), "the one not found is looked for again on its own"); + // focused all along, every answer "old": nothing is asked again + for _ in 0..30 { + h.step(); + } + assert_eq!(calls.load(Ordering::SeqCst), 1, "no check on a timer"); + // the window went to the back and comes to the front again + h.app.recent_checks.focused = false; + assert!(h.step_until(T, |_| calls.load(Ordering::SeqCst) == 2), "asked again when the window comes back"); + for _ in 0..10 { + h.step(); + } + assert_eq!(calls.load(Ordering::SeqCst), 2, "once"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A library the launch list showed as not found comes back to it once found again (a drive plugged +/// back in while the list is up): the row waits for the new check instead of keeping its old answer +/// (FredApps on #2689, #2774). +#[test] +fn a_library_found_again_comes_back_to_the_launch_list() { + use std::sync::atomic::{AtomicBool, Ordering}; + let dir = temp("found-again"); + let a = make_library(&dir.join("A")); + let plugged = std::sync::Arc::new(AtomicBool::new(false)); + let p = plugged.clone(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.recent_checks = crate::panels::library_problem::RecentChecks::with_check(move |_| { + (!p.load(Ordering::SeqCst)).then_some(crate::panels::library_problem::Unavailable::NotFound) + }) + .recheck_after(Duration::ZERO); + app.ui.settings.recent_libraries = vec![a.clone()]; + app.library_problem = Some(LibraryProblem::choose_later(&app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let row = |h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).map(|c| c.libraries[0].clone()).unwrap(); + assert!( + h.step_until(T, |h| { + let r = row(h); + !r.checking && r.unavailable.is_some() + }), + "not found first" + ); + plugged.store(true, Ordering::SeqCst); + assert!( + h.step_until(T, |h| { + let r = row(h); + !r.checking && r.unavailable.is_none() + }), + "found again: it can be opened, {:?}", + row(&h) + ); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_lightroom_destination.rs b/crates/ui-egui/src/tests_lightroom_destination.rs new file mode 100644 index 000000000..b2def730b --- /dev/null +++ b/crates/ui-egui/src/tests_lightroom_destination.rs @@ -0,0 +1,426 @@ +//! Issue #2672: before File ▸ Import Lightroom Catalog… starts, a window asks whether the catalog +//! merges into the open library or goes into a new library (named after the catalog, next to the +//! open one), unless Settings (or the window's "Don't ask again") says to always merge. +//! +//! The catalogs here are fake (`.lrcat` files that aren't SQLite): what is checked is where the +//! import is started, which library is open when it starts, and that the read then fails cleanly. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::{Dialog, LightroomImport}; +use crate::{LightcraftApp, Services, UiState}; + +const T: Duration = Duration::from_secs(20); + +/// A folder of its own per test: `Library` (a LightCraft library, demo photos in it unless +/// `empty`) and `Lightroom/Travel.lrcat` (a fake catalog) side by side. +struct Fixture { + /// The test's own folder. + root: PathBuf, + /// `root/Library`. + lib: PathBuf, + /// `root/Lightroom/Travel.lrcat`. + lrcat: PathBuf, +} + +impl Fixture { + /// Make the folders and the fake catalog (an earlier run's leftovers removed). + fn new(tag: &str) -> Fixture { + let root = std::env::temp_dir().join(format!("lc-ui-lrdest-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let lr = root.join("Lightroom"); + std::fs::create_dir_all(&lr).unwrap(); + let lrcat = lr.join("Travel.lrcat"); + std::fs::write(&lrcat, b"not really SQLite").unwrap(); + Fixture { lib: root.join("Library"), root, lrcat } + } + + /// The desktop app on `Library`, with a folder dialog (for Choose…) and screenshots. + fn app(&self, empty: bool) -> Headless { + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&self.lib, !empty).unwrap(); + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + // (a folder dialog for the window's Choose…, and what a screenshot needs) + let services = Services { + pick_folder: Some(Box::new(|| None)), + png: Some(Box::new(png)), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut h = Headless::new(LightcraftApp::new(session, services), [1200.0, 800.0], 1.0); + h.step(); + h + } + + /// The catalog's path as text. + fn lrcat(&self) -> String { + self.lrcat.to_string_lossy().into_owned() + } +} + +impl Drop for Fixture { + /// Remove the folders. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// The widget `id` was drawn last frame. +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// Click the widget `id` through the control channel, then draw a frame. +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); +} + +/// The open library's folder. +fn open_dir(h: &Headless) -> Option { + h.app.session.library.as_ref().map(|l| l.dir.clone()) +} + +/// The window's state: (new library chosen, name, location, don't ask). +fn destination(h: &Headless) -> Option<(bool, String, String, bool)> { + match &h.app.ui.dialog { + Some(Dialog::LightroomDestination { new_library, name, location, dont_ask, .. }) => { + Some((*new_library, name.clone(), location.clone(), *dont_ask)) + } + _ => None, + } +} + +/// Let the import of the fake catalog end (it fails to read) and return what it reported. +fn import_ends(h: &mut Headless, lrcat: &Path) -> Value { + assert!(h.step_until(T, |h| h.app.lightroom.is_none()), "the import never ended"); + let last = h.app.lightroom_last.clone().unwrap_or_default(); + assert_eq!(last["path"], json!(lrcat), "the import was of this catalog: {last}"); + last +} + +/// A headless screenshot into `$LIGHTCRAFT_TEST_SHOTS`, when it is set. +fn shot(h: &mut Headless, name: &str) { + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": Path::new(&p).join(name).to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } +} + +/// The window appears before anything starts, with its choices; Merge imports into the open library. +#[test] +fn the_window_asks_and_merge_imports_into_the_open_library() { + let f = Fixture::new("merge"); + let mut h = f.app(false); + let photos = h.app.session.catalog.len(); + assert!(photos > 0); + + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r, json!({"asking": true, "path": f.lrcat(), "into": "new"})); + h.step(); + h.step(); + // a library with photos: the window opens on a new library, named after the catalog, next to the open one + assert_eq!(destination(&h), Some((true, "Travel".into(), f.root.to_string_lossy().into_owned(), false))); + for id in [ + "radio:lightroomDestination-open", + "radio:lightroomDestination-new", + "field:lightroomLibraryName", + "field:lightroomLibraryLocation", + "button:lightroomLibraryChoose", + "check:lightroomDontAsk", + "button:dialogOk", + "button:dialogCancel", + ] { + assert!(has(&h, id), "{id} is missing"); + } + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none(), "nothing starts before the answer"); + shot(&mut h, "lightroom-destination-2672.png"); + + // Merge into the open library + click(&mut h, "radio:lightroomDestination-open"); + assert_eq!(destination(&h).map(|d| d.0), Some(false)); + click(&mut h, "button:dialogOk"); + assert!(destination(&h).is_none()); + assert!(h.app.lightroom.is_some() || h.app.lightroom_last.is_some(), "the import started"); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(f.lib.clone()), "into the library open before"); + assert_eq!(h.app.session.catalog.len(), photos); + assert!(!f.root.join("Travel").exists(), "no library was made"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::Ask, "it asks again next time"); +} + +/// Create a new library: it is made next to the open one, named after the catalog, and opened +/// before the import starts there. +#[test] +fn create_a_new_library_opens_it_before_the_import_starts() { + let f = Fixture::new("new"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + assert_eq!(destination(&h).map(|d| d.0), Some(true)); + // ui.dialog.confirm answers what the window's button does + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + let new = f.root.join("Travel"); + let result = &r["result"]; + assert_eq!(result["library"]["path"], json!(new.to_string_lossy()), "{r}"); + assert_eq!(result["library"]["name"], "Travel", "{r}"); + assert_eq!(result["path"], json!(f.lrcat()), "{r}"); + // the new library is the open one, and it was when the import started (the import's + // snapshot is taken in the same call, after the switch) + assert_eq!(open_dir(&h), Some(new.clone())); + assert!(lightcraft_engine::library::is_library(&new)); + assert_eq!(h.app.ui.settings.library_path, new.to_string_lossy(), "opened at the next launch"); + assert!(h.app.ui.settings.recent_libraries.contains(&f.lib.to_string_lossy().into_owned()), "the old one is a recent library"); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(new)); +} + +/// Cancel: no import, no library made, the open library stays. +#[test] +fn cancel_does_nothing() { + let f = Fixture::new("cancel"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + assert!(destination(&h).is_some()); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + assert_eq!(open_dir(&h), Some(f.lib.clone())); + assert!(!f.root.join("Travel").exists()); +} + +/// An empty library has nothing to merge with: the window opens on Merge. +#[test] +fn an_empty_library_opens_the_window_on_merge() { + let f = Fixture::new("empty"); + let mut h = f.app(true); + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["into"], "open", "{r}"); + h.step(); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomDestination { new_library: false, photos: 0, .. })), "{:?}", h.app.ui.dialog); + // (the window in French, which translates it: the language is this test thread's own) + if std::env::var_os("LIGHTCRAFT_TEST_SHOTS").is_some() { + h.app.run("app.language.french", json!({})).unwrap(); + h.step(); + h.step(); + shot(&mut h, "lightroom-destination-empty-fr-2672.png"); + h.app.run("app.language.english", json!({})).unwrap(); + } +} + +/// "Always merge into the open library" imports without the window. +#[test] +fn always_merge_skips_the_window() { + let f = Fixture::new("always"); + let mut h = f.app(false); + h.app.ui.settings.lightroom_import = LightroomImport::AlwaysMerge; + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + h.step(); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(f.lib.clone())); +} + +/// "Don't ask again" (with Merge only) sets "Always merge", which is saved in `ui.json`. +#[test] +fn dont_ask_again_merges_from_then_on_and_is_saved() { + let f = Fixture::new("dontask"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + // greyed out while a new library is chosen: it only speaks of merging + click(&mut h, "check:lightroomDontAsk"); + assert_eq!(destination(&h).map(|d| d.3), Some(false)); + click(&mut h, "radio:lightroomDestination-open"); + click(&mut h, "check:lightroomDontAsk"); + assert_eq!(destination(&h).map(|d| d.3), Some(true)); + click(&mut h, "button:dialogOk"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::AlwaysMerge); + import_ends(&mut h, &f.lrcat); + // saved with the UI state (ui.json) and read back + let saved = serde_json::to_string(&h.app.ui).unwrap(); + assert!(saved.contains(r#""lightroomImport":"alwaysMerge""#), "{saved}"); + let back: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(back.settings.lightroom_import, LightroomImport::AlwaysMerge); + // the next import doesn't ask + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); +} + +/// Settings written before the setting existed, or hand-edited, ask (the default). +#[test] +fn old_and_odd_settings_files_ask() { + let old: UiState = serde_json::from_str(r#"{"settings": {"startupView": "detail"}}"#).unwrap(); + assert_eq!(old.settings.lightroom_import, LightroomImport::Ask); + let odd: UiState = serde_json::from_str(r#"{"settings": {"lightroomImport": "sometimes", "startupView": "detail"}}"#).unwrap(); + assert_eq!(odd.settings.lightroom_import, LightroomImport::Ask); + assert_eq!(odd.settings.startup_view, crate::state::StartupView::Detail, "the rest of the file still loads"); +} + +/// Settings ▸ General ▸ When importing a Lightroom catalog switches between the two. +#[test] +fn settings_general_switches_the_question_off_and_on() { + let f = Fixture::new("settings"); + let mut h = f.app(false); + h.app.run("app.settings", json!({})).unwrap(); + h.step(); + h.step(); + click(&mut h, "button:settingsLightroomImport-1"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::AlwaysMerge); + shot(&mut h, "settings-lightroom-import-2672.png"); + click(&mut h, "button:settingsLightroomImport-0"); + assert_eq!(h.app.ui.settings.lightroom_import, LightroomImport::Ask); +} + +/// A new library's folder must be new or empty (File ▸ New Library…'s rule): the window says so +/// and stays open to pick another name; nothing is imported and the library stays. +#[test] +fn a_folder_that_isnt_empty_is_refused_and_the_window_stays() { + let f = Fixture::new("notempty"); + std::fs::create_dir_all(f.root.join("Travel")).unwrap(); + std::fs::write(f.root.join("Travel").join("notes.txt"), b"mine").unwrap(); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + h.step(); + // the free name is offered… + assert_eq!(destination(&h).map(|d| d.1), Some("Travel 2".to_string())); + // …but the user types the taken one + if let Some(Dialog::LightroomDestination { name, .. }) = h.app.ui.dialog.as_mut() { + *name = "Travel".into(); + } + click(&mut h, "button:dialogOk"); + assert!(destination(&h).is_some(), "the window stays to be corrected"); + let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); + assert!(toast.contains("isn't empty"), "{toast}"); + assert_eq!(open_dir(&h), Some(f.lib.clone())); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + // the same through the control channel + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "{r}"); + assert!(destination(&h).is_some()); +} + +/// `into` acts without asking (agents, scripts); bad params are refused before anything happens. +#[test] +fn into_acts_without_the_window() { + let f = Fixture::new("into"); + let mut h = f.app(false); + for bad in [json!({"path": f.lrcat(), "into": "elsewhere"}), json!({"path": f.lrcat(), "into": "new", "location": "relative/dir"})] { + let err = h.app.run("file.importLightroom", bad.clone()).unwrap_err(); + assert!(err.contains("file.importLightroom"), "{bad}: {err}"); + } + // a catalog that isn't there doesn't leave an empty library behind + let missing = f.root.join("Gone.lrcat"); + let err = h.app.run("file.importLightroom", json!({"path": missing.to_string_lossy(), "into": "new"})).unwrap_err(); + assert!(err.contains("no Lightroom catalog"), "{err}"); + assert!(!f.root.join("Gone").exists()); + assert!(destination(&h).is_none() && h.app.lightroom_last.is_none()); + + let elsewhere = f.root.join("Libraries"); + let r = h + .app + .run("file.importLightroom", json!({"path": f.lrcat(), "into": "new", "name": "Trips", "location": elsewhere.to_string_lossy()})) + .unwrap(); + assert_eq!(r["library"]["name"], "Trips", "{r}"); + assert_eq!(open_dir(&h), Some(elsewhere.join("Trips"))); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); + + // a new library while another import runs: refused before anything is made + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat(), "into": "open"})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + let err = h.app.run("file.importLightroom", json!({"path": f.lrcat(), "into": "new", "name": "Again"})).unwrap_err(); + assert!(err.contains("already running"), "{err}"); + assert!(!elsewhere.join("Again").exists()); + // and the window isn't shown for an answer that couldn't be carried out + assert!(h.app.run("file.importLightroom", json!({"path": f.lrcat()})).is_err()); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); + assert_eq!(open_dir(&h), Some(elsewhere.join("Trips"))); +} + +/// A session in memory (no library on disk) has nothing to put a new library next to: it merges +/// without asking, as before. +#[test] +fn a_library_in_memory_merges_without_asking() { + let f = Fixture::new("memory"); + let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()), [1200.0, 800.0], 1.0); + h.step(); + let r = h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + assert!(destination(&h).is_none()); + import_ends(&mut h, &f.lrcat); +} + +/// Another library opened while the window was up (an agent, the control channel): the window +/// doesn't merge into it. +#[test] +fn merge_is_refused_when_another_library_was_opened_meanwhile() { + let f = Fixture::new("stale"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + click(&mut h, "radio:lightroomDestination-open"); + let other = f.root.join("Other"); + let dialog = h.app.ui.dialog.take(); + h.app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap(); + let stale = dialog.clone(); + h.app.ui.dialog = dialog; + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the window closes: the library it named is gone"); + // confirm's own error, not the last toast: another notice (the library switch's, on macOS) + // can replace the toast before it is read + let stale = stale.unwrap(); + let err = crate::lightroom_destination::confirm(&mut h.app, &stale).unwrap_err(); + assert!(err.contains("nothing was imported"), "{err}"); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + assert_eq!(open_dir(&h), Some(other)); +} + +/// The same for *Create a new library* (CodeRabbit on #2685): a window left over from before a +/// library switch makes no library and imports nothing. +#[test] +fn a_new_library_is_refused_when_another_library_was_opened_meanwhile() { + let f = Fixture::new("stale-new"); + let mut h = f.app(false); + h.app.run("file.importLightroom", json!({"path": f.lrcat()})).unwrap(); + h.step(); + click(&mut h, "radio:lightroomDestination-new"); + let other = f.root.join("Other"); + let dialog = h.app.ui.dialog.take(); + let target = match &dialog { + Some(crate::state::Dialog::LightroomDestination { name, location, .. }) => std::path::Path::new(location).join(name), + _ => panic!("the destination window is up"), + }; + h.app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap(); + let stale = dialog.clone(); + h.app.ui.dialog = dialog; + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the window closes: the library it named is gone"); + // confirm's own error, not the last toast: another notice (the library switch's, on macOS) + // can replace the toast before it is read + let stale = stale.unwrap(); + let err = crate::lightroom_destination::confirm(&mut h.app, &stale).unwrap_err(); + assert!(err.contains("nothing was imported"), "{err}"); + assert!(h.app.lightroom.is_none() && h.app.lightroom_last.is_none()); + assert_eq!(open_dir(&h), Some(other)); + assert!(!target.exists(), "no library was made at {}", target.display()); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 2ce24355f..c8d6d6c2e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1192,6 +1192,29 @@ fn edit_in_external_editor_opens_the_copy() { h.settle(SETTLE); } +/// An editor that won't open the copy: the copy is in the library already, so the command succeeds +/// with `failed` set and a toast saying why, instead of an error a script would retry (and make +/// another copy each time). +#[test] +fn edit_in_reports_an_editor_that_wont_open_without_failing() { + let dir = std::env::temp_dir().join(format!("lc-ui-ext-fail-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let services = Services { png: None, open_with: Some(Box::new(|_: &str, _: &str| Err("the editor quit".into()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "PhotoCraft".into(); + let n = h.app.session.catalog.len(); + let r = h.request("engine.execute", json!({"command": "photo.editInExternal", "params": {"dir": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["failed"], "the editor quit", "{r}"); + assert_eq!(h.app.session.catalog.len(), n + 1, "the copy stays in the library"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("the editor quit"), "{toast}"); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + /// Edit in PhotoCraft: a PSD copy (what PhotoCraft saves back to), handed to "PhotoCraft" for /// the host to find; when the host can't find it, no copy is made. #[test] @@ -1224,6 +1247,12 @@ fn edit_in_photocraft_opens_a_psd_copy() { let r = h.request("engine.execute", params.clone(), T); assert_eq!(r["ok"], false, "{r}"); assert_eq!((h.app.session.catalog.len(), opened.lock().unwrap().len()), (n, 0), "no copy without PhotoCraft"); + // and it says so, however it was asked for (menu, shortcut, context menu) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(!toast.is_empty(), "the missing editor is reported"); + h.app.ui.toast = None; + let r = h.app.run("photo.editInPhotoCraft", json!({})); + assert!(r.is_err() && h.app.ui.toast.is_some(), "{r:?} {:?}", h.app.ui.toast); installed.store(true, std::sync::atomic::Ordering::SeqCst); let r = h.request("engine.execute", params, T); assert_eq!(r["ok"], true, "{r}"); diff --git a/crates/ui-egui/src/tests_merge_preview.rs b/crates/ui-egui/src/tests_merge_preview.rs new file mode 100644 index 000000000..09419758d --- /dev/null +++ b/crates/ui-egui/src/tests_merge_preview.rs @@ -0,0 +1,74 @@ +//! Headless tests of the Photo Merge dialog's preview: click to zoom in, drag to pan, click to fit. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(120); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn press(h: &mut Headless, pos: egui::Pos2, pressed: bool) { + h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); +} + +fn click_at(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(pos)); + press(h, pos, true); + press(h, pos, false); + h.settle(T); +} + +#[test] +fn a_click_on_the_merge_preview_zooms_in_and_a_drag_pans() { + let dir = std::env::temp_dir().join(format!("lc-merge-zoom-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.settle(T); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + assert!(h.step_until(T, |h| h.app.merge.preview.is_some() && h.app.merge.preview_task.is_none()), "the preview rendered"); + h.settle(T); + assert_eq!(h.app.merge.zoom, None, "the preview opens fitted"); + + let area = rect(&h, "merge.preview"); + // right of and above the middle: a point of the fitted image whatever its aspect + let at = area.center() + egui::vec2(area.width() * 0.2, area.height() * -0.1); + click_at(&mut h, at); + let (u, v) = h.app.merge.zoom.expect("the click zoomed in"); + assert!(u > 0.55 && v < 0.5, "zoomed in on the point clicked ({u}, {v})"); + + // drag left: the image follows the pointer, so a point further right comes to the middle + let from = area.center(); + let to = from - egui::vec2(60.0, 0.0); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + press(&mut h, from, true); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + press(&mut h, to, false); + h.settle(T); + let (u2, v2) = h.app.merge.zoom.expect("still zoomed in after a drag"); + assert!(u2 > u + 0.01 && (v2 - v).abs() < 1e-3, "the drag panned right ({u}, {v}) → ({u2}, {v2})"); + + click_at(&mut h, area.center()); + assert_eq!(h.app.merge.zoom, None, "a second click fits the preview again"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests_open_lightroom.rs index cddfa2bef..64de477fe 100644 --- a/crates/ui-egui/src/tests_open_lightroom.rs +++ b/crates/ui-egui/src/tests_open_lightroom.rs @@ -1,6 +1,6 @@ //! Issue #891: File ▸ Open Library… given a Lightroom Classic catalog folder doesn't make a //! LightCraft library inside it. The library open now stays, and a dialog offers Import Lightroom -//! Catalog… with the catalog found, which starts the import of that `.lrcat`. +//! Catalog… with the catalog found, which asks where that `.lrcat` goes (#2672), then imports it. use std::time::Duration; @@ -84,12 +84,23 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { assert_eq!(r["lightroomCatalog"], json!(lrcat.to_string_lossy()), "{r}"); h.step(); h.step(); - // Import Lightroom Catalog…: the import of that catalog starts (no file dialog), into the - // library open now. (This fake catalog then fails to read: only its path matters here.) + // Import Lightroom Catalog…: no file dialog, the window that asks where that catalog goes + // (#2672) takes its place, as File ▸ Import Lightroom Catalog… does + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); + h.step(); + assert!( + matches!(&h.app.ui.dialog, Some(Dialog::LightroomDestination { catalog, .. }) if *catalog == lrcat.to_string_lossy()), + "{:?}", + h.app.ui.dialog + ); + // Merge: the import of that catalog starts, into the library open now. (This fake catalog + // then fails to read: only its path matters here.) + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:lightroomDestination-open"}), T)["ok"], true); + h.step(); assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); h.step(); // A fast read failure can already have replaced the offer with its lasting result. - assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. }))); + assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. } | Dialog::LightroomDestination { .. }))); assert!(h.step_until(T, |h| h.app.lightroom.is_none())); assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed"))); let last = h.app.lightroom_last.clone().unwrap_or_default(); diff --git a/crates/ui-egui/src/tests_preset_favorites.rs b/crates/ui-egui/src/tests_preset_favorites.rs new file mode 100644 index 000000000..0dc9900ea --- /dev/null +++ b/crates/ui-egui/src/tests_preset_favorites.rs @@ -0,0 +1,81 @@ +//! Issue #1999: starred presets are listed in a Favorites section at the top of the Presets panel, +//! as starred profiles are in the Profile browser, and stay in their own group too. + +use crate::{LightcraftApp, Services, headless::Headless}; +use serde_json::json; + +/// A headless app on the demo library with the Presets panel open and laid out. +fn app() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.app.run("panel.presets", json!({})).unwrap(); + for _ in 0..5 { + h.step(); + } + h +} + +/// Where the widget registered as `id` was drawn in the last frame, if it was. +fn rect(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +/// The topmost preset group header on screen (the Favorites header is `presetFavorites`). +fn first_group(h: &Headless) -> String { + let groups = h.app.widgets.iter().filter(|(w, _)| w.starts_with("presetGroup:") || w == "presetFavorites"); + groups.min_by(|a, b| a.1.top().total_cmp(&b.1.top())).map(|(w, _)| w.clone()).unwrap() +} + +/// Starring two presets makes a Favorites section the first header, listing them in list order +/// right under it, while each stays in its own group too. +#[test] +fn starred_presets_are_listed_in_favorites_on_top() { + let mut h = app(); + assert!(rect(&h, "presetFavorites").is_none(), "no section while nothing is starred"); + let first = h.app.session.presets.first().map(|p| p.id.clone()).unwrap(); + let last = h.app.session.presets.last().map(|p| p.id.clone()).unwrap(); + h.app.run("preset.favorite", json!({"id": last})).unwrap(); + h.app.run("preset.favorite", json!({"id": first})).unwrap(); + for _ in 0..3 { + h.step(); + } + assert_eq!(first_group(&h), "presetFavorites"); + let header = rect(&h, "presetFavorites").unwrap(); + // in list order, right under the header + let a = rect(&h, &format!("presetFavorite:{first}")).unwrap(); + let b = rect(&h, &format!("presetFavorite:{last}")).unwrap(); + assert!(header.bottom() <= a.top() && a.top() < b.top(), "{header:?} {a:?} {b:?}"); + // still in its own group + assert!(rect(&h, &format!("preset:{first}")).is_some()); + // unstarring the last one removes the section + for id in [&first, &last] { + h.app.run("preset.favorite", json!({"id": id})).unwrap(); + } + for _ in 0..3 { + h.step(); + } + assert!(rect(&h, "presetFavorites").is_none()); +} + +/// A user group named "Favorites" or "favorites" keeps its own header, apart from the section's: a +/// click aimed at either id lands on the right row (the widget lookup takes the last match). +#[test] +fn user_groups_named_favorites_keep_their_own_header() { + let mut h = app(); + let id = h.app.session.presets.first().map(|p| p.id.clone()).unwrap(); + h.app.run("preset.favorite", json!({"id": id})).unwrap(); + for group in ["Favorites", "favorites"] { + h.app.run("preset.create", json!({"name": format!("Mine in {group}"), "group": group})).unwrap(); + } + for _ in 0..3 { + h.step(); + } + let section = rect(&h, "presetFavorites").unwrap(); + for group in ["Favorites", "favorites"] { + let id = format!("presetGroup:{group}"); + let user = rect(&h, &id).unwrap_or_else(|| panic!("the user's {group:?} group is listed")); + assert_ne!(user, section, "{id} is its own row"); + assert_eq!(h.app.widgets.iter().filter(|(w, _)| *w == id).count(), 1, "{id} names one row"); + } + assert_eq!(h.app.widgets.iter().filter(|(w, _)| w == "presetFavorites").count(), 1); +} diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 070921256..e87e52355 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -284,6 +284,25 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // Given a diagnostic overlay (Visualize Spots), when I zoom to 1:1, then the visible part is a + // window render carrying the overlay, over a canvas-sized whole frame, as without it + #[test] + fn visualize_spots_at_one_to_one_is_a_window() { + let (mut h, native) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let plain = h.app.region_view.expect("a window render without the overlay"); + let r = h.request("ui.menu.invoke", json!({"id": "view.visualizeSpots"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.visualize_spots); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let spots = h.app.region_view.expect("a window render with the overlay"); + assert_eq!(spots.full.0.max(spots.full.1), native, "cut from the photo's own pixels"); + assert_ne!(spots.settings, plain.settings, "the overlay is part of the window's look"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { @@ -530,13 +549,16 @@ mod in_the_loupe { assert!(main <= small, "the draft is {main} px, a small one at most {small}"); } - // Given soft proofing (rendered on the CPU even with a GPU), a drag drafts small (#330) + // Given soft proofing against an output-device profile (rendered on the CPU even with a GPU), a + // drag drafts small (#330); proofs into an output space run on the GPU like any render #[test] fn a_soft_proofed_drag_drafts_small() { // the GPU device up front (the loupe never waits for it): with one, this is the case where // the GPU is there and the render still goes to the CPU let _ = lightcraft_engine::gpu::available(); let (mut h, _) = detail(); + let lut = lightcraft_pipeline::ProofLut::new(2, vec![[0.5, 0.5, 0.5, 0.0]; 8]).expect("a valid table"); + h.app.ui.proof.profile = lightcraft_pipeline::register_proof_profile(0x5eed_9f01, lut); h.app.ui.soft_proof = true; h.settle(SETTLE); let (main, small) = drag_exposure(&mut h); diff --git a/crates/ui-egui/src/tests_progress.rs b/crates/ui-egui/src/tests_progress.rs new file mode 100644 index 000000000..476b8a1a6 --- /dev/null +++ b/crates/ui-egui/src/tests_progress.rs @@ -0,0 +1,284 @@ +//! Progress bars, spinners and busy buttons: the fraction is always a sane number, a bar is +//! announced as progress with its percentage and reachable by its id, a busy button is announced +//! busy, ignores clicks and keeps its width, and a thumbnail's placeholder stands apart from what +//! it sits on in every theme. + +use egui::{Pos2, Rect, pos2, vec2}; + +use crate::button::{Button, contrast}; +use crate::icons::Icon; +use crate::progress::{Progress, clamp_fraction, fraction_of, spinner}; +use crate::theme::{ThemeKind, Tokens}; + +/// What a screen reader gets for one node. +#[derive(Debug, Clone, PartialEq)] +struct Node { + role: String, + label: String, + busy: bool, + value: Option, +} + +/// Run three frames of `draw` (pointer `events` in the second); return the nodes of the last +/// frame with an accessibility tree, the automation ids registered in the last frame, and what +/// `draw` returned. +fn frames(events: Vec, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec, Vec<(String, Rect)>, T) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let (mut nodes, mut ids, mut last) = (Vec::new(), Vec::new(), T::default()); + for frame in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: if frame == 1 { events.clone() } else { Vec::new() }, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| last = draw(ui)); + out.textures_delta.clear(); + ids = crate::widgets::take_registry(&ctx); + if let Some(update) = out.platform_output.accesskit_update.take() { + nodes = update + .nodes + .iter() + .map(|(_, n)| Node { + role: format!("{:?}", n.role()), + label: n.label().unwrap_or_default().to_string(), + busy: n.is_busy(), + value: n.numeric_value(), + }) + .collect(); + } + } + (nodes, ids, last) +} + +#[test] +fn the_fraction_is_always_between_nothing_and_all() { + assert_eq!(clamp_fraction(f32::NAN), 0.0); + assert_eq!(clamp_fraction(-3.0), 0.0); + assert_eq!(clamp_fraction(7.5), 1.0); + assert_eq!(clamp_fraction(f32::INFINITY), 1.0); + assert_eq!(clamp_fraction(f32::NEG_INFINITY), 0.0); + assert_eq!(clamp_fraction(0.25), 0.25); + assert_eq!(Progress::new(Some(f32::NAN)).fraction(), Some(0.0)); + assert_eq!(Progress::new(Some(2.0)).fraction(), Some(1.0)); + assert_eq!(Progress::new(None).fraction(), None); +} + +#[test] +fn an_unknown_total_is_an_indeterminate_bar_not_an_empty_one() { + assert_eq!(fraction_of(0, 0), None); + assert_eq!(fraction_of(5, 0), None); + assert_eq!(fraction_of(0, 10), Some(0.0)); + assert_eq!(fraction_of(5, 10), Some(0.5)); + assert_eq!(fraction_of(20, 10), Some(1.0)); +} + +#[test] +fn a_bar_is_progress_with_a_value_and_reachable_by_its_id() { + let (nodes, ids, _) = frames(Vec::new(), |ui| { + Progress::new(Some(0.42)).label("Making the denoised picture… 42%").widget("denoise:progress").show(ui); + }); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("no progress node: {nodes:?}")); + assert_eq!(bar.label, "Making the denoised picture… 42%"); + assert_eq!(bar.value, Some(42.0)); + assert!(ids.iter().any(|(id, r)| id == "denoise:progress" && r.width() > 0.0), "{ids:?}"); +} + +#[test] +fn an_indeterminate_bar_says_progress_without_a_value_and_is_drawn() { + let (nodes, ids, width) = frames(Vec::new(), |ui| Progress::new(None).label("Starting…").widget("p:x").show(ui).rect.width()); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + assert_eq!(bar.value, None); + assert!(ids.iter().any(|(id, _)| id == "p:x")); + assert!(width >= 96.0, "fills the width the layout has"); +} + +#[test] +fn a_bar_fills_the_layout_unless_given_a_width() { + let (_, _, (fill, fixed, tiny_room)) = frames(Vec::new(), |ui| { + let fill = Progress::new(Some(0.5)).show(ui).rect.width(); + let fixed = Progress::new(Some(0.5)).width(150.0).show(ui).rect.width(); + let tiny_room = ui.scope(|ui| { + ui.set_max_width(40.0); + Progress::new(Some(0.5)).show(ui).rect.width() + }); + (fill, fixed, tiny_room.inner) + }); + assert!(fill > 300.0, "fills: {fill}"); + assert_eq!(fixed, 150.0); + assert!(tiny_room >= 96.0, "never a sliver: {tiny_room}"); +} + +#[test] +fn a_spinner_takes_the_room_it_is_given() { + let (_, _, size) = frames(Vec::new(), |ui| spinner(ui, 14.0).rect.size()); + assert_eq!(size, vec2(14.0, 14.0)); +} + +fn click_at(at: Pos2) -> Vec { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ] +} + +#[test] +fn a_busy_button_ignores_clicks_and_is_announced_busy() { + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let click = |busy: bool| { + let mut clicked = false; + let (nodes, _, ()) = frames(click_at(pos2(40.0, 15.0)), |ui| clicked |= Button::new("Analyze").busy(busy).show_at(ui, place).clicked()); + (nodes, clicked) + }; + let (nodes, clicked) = click(true); + assert!(!clicked, "a busy button does nothing"); + let node = nodes.iter().find(|n| n.role == "Button" && n.label == "Analyze").unwrap_or_else(|| panic!("{nodes:?}")); + assert!(node.busy, "announced busy"); + + let (nodes, clicked) = click(false); + assert!(clicked, "the same button, not busy, is clicked"); + assert!(!nodes.iter().any(|n| n.busy), "{nodes:?}"); +} + +#[test] +fn a_busy_button_stays_reachable_by_the_keyboard() { + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let mut asked = false; + let (_, _, focused) = frames(Vec::new(), |ui| { + let r = Button::new("Analyze").busy(true).show_at(ui, place); + if !std::mem::replace(&mut asked, true) { + r.request_focus(); + } + r.has_focus() + }); + assert!(focused, "focus is kept while it works"); +} + +#[test] +fn a_button_keeps_its_width_while_busy() { + for (text, icon) in [("Analyze", None), ("Analyze", Some(Icon::Trash)), ("", Some(Icon::Trash))] { + for small in [false, true] { + let (_, _, (idle, busy)) = frames(Vec::new(), |ui| { + let mut make = |b: bool| { + let mut btn = if text.is_empty() { Button::icon_only(Icon::Trash, "Remove") } else { Button::new(text) }; + if let Some(i) = icon { + btn = btn.icon(i); + } + if small { + btn = btn.small(); + } + btn.busy(b).show(ui).rect.size() + }; + (make(false), make(true)) + }); + assert_eq!(idle, busy, "{text:?} {icon:?} small={small}"); + } + } +} + +#[test] +fn the_thumbnail_placeholder_stands_apart_in_every_theme() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for (name, behind) in [("grid", t.grid_bg), ("cell", t.cell), ("selected cell", t.cell_selected), ("canvas", t.canvas)] { + let c = contrast(t.skeleton, behind); + // the light themes' surfaces are close together, so the placeholder has to be set + // further off them to read at all + let floor = if matches!(kind, ThemeKind::Silver | ThemeKind::Paper) { 1.15 } else { 1.1 }; + assert!(c >= floor, "{kind:?}: the placeholder on the {name} is {c:.2}:1"); + } + let mark = contrast(t.text, t.skeleton); + assert!(mark >= 3.0, "{kind:?}: the failure mark on the placeholder is {mark:.2}:1"); + } +} + +#[test] +fn a_failed_filmstrip_thumbnail_is_announced_with_its_reason() { + let spoken = |failed: bool| { + let (nodes, _, _) = frames(Vec::new(), |ui| { + let resp = ui.interact(Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 80.0)), egui::Id::new("film-test"), egui::Sense::click()); + crate::widgets::announce(&resp, crate::panels::detail::film_spoken("IMG_0001.jpg", failed)); + }); + nodes.into_iter().find(|n| n.role == "Button").map(|n| n.label) + }; + assert_eq!(spoken(false).as_deref(), Some("IMG_0001.jpg")); + assert_eq!(spoken(true).as_deref(), Some("IMG_0001.jpg, This photo can't be opened")); +} + +#[test] +fn a_failed_filmstrip_thumbnail_without_a_file_name_has_no_leading_comma() { + assert_eq!(crate::panels::detail::film_spoken("", true), "This photo can't be opened"); + assert_eq!(crate::panels::detail::film_spoken("", false), ""); +} + +#[test] +fn the_sam_bar_keeps_its_name_and_height_while_the_size_is_unknown() { + let (nodes, _, (known, unknown)) = frames(Vec::new(), |ui| { + let known = crate::panels::masking::sam_download_bar(ui, 5, 10, 200.0).rect.height(); + let unknown = crate::panels::masking::sam_download_bar(ui, 0, 0, 200.0).rect.height(); + (known, unknown) + }); + assert_eq!(known, unknown); + let bars: Vec<_> = nodes.iter().filter(|n| n.role == "ProgressIndicator").collect(); + assert_eq!(bars.len(), 2, "{nodes:?}"); + assert!(bars.iter().all(|b| b.label.starts_with("SAM 3")), "{bars:?}"); +} + +#[test] +fn the_denoise_bars_keep_their_widths_in_a_wide_container() { + let (_, ids, (detail, settings, unknown)) = frames(Vec::new(), |ui| { + let detail = crate::panels::denoise::detail_bar(ui, 3, 10).rect.width(); + let settings = crate::panels::denoise::settings_bar(ui, 3, 10, "Denoising a.jpg: 3 of 10 tiles".into()).rect.width(); + let unknown = crate::panels::denoise::detail_bar(ui, 0, 0).rect.width(); + (detail, settings, unknown) + }); + assert_eq!((detail, settings, unknown), (240.0, 380.0, 240.0)); + assert!(ids.iter().any(|(id, _)| id == "denoise:settingsProgress")); +} + +#[test] +fn the_denoise_bar_label_and_its_accessible_value_agree() { + // the number drawn on the bar is the one announced, and exact decimals stay exact + for (done, total, says) in + [(3, 7, 42.0), (53, 100, 53.0), (59, 100, 59.0), (29, 100, 29.0), (99, 100, 99.0), (100, 100, 100.0), (999, 1000, 99.0)] + { + let (nodes, _, _) = frames(Vec::new(), |ui| { + crate::panels::denoise::detail_bar(ui, done, total); + }); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + let shown: f64 = bar.label.rsplit(' ').next().unwrap().trim_end_matches('%').parse().unwrap(); + assert_eq!((shown, bar.value), (says, Some(says)), "{done}/{total}: {bar:?}"); + } +} + +#[test] +fn the_sam_bar_text_and_its_accessible_value_agree() { + for (done, total, says) in [(53, 100, 53.0), (59, 100, 59.0), (29, 100, 29.0), (999, 1000, 99.0), (100, 100, 100.0)] { + let (nodes, _, _) = frames(Vec::new(), |ui| crate::panels::masking::sam_download_bar(ui, done, total, 200.0).rect.width()); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + assert_eq!(bar.label, format!("SAM 3: {says} %"), "{done}/{total}"); + assert_eq!(bar.value, Some(says), "{done}/{total}"); + } +} + +#[test] +fn an_activity_bar_is_named_after_its_task() { + let task = lightcraft_engine::activity::TaskInfo { + id: 1, + kind: "export", + label: "Exporting".into(), + done: 3, + total: 25, + unit: lightcraft_engine::activity::Unit::Count, + detail: String::new(), + cancellable: true, + cancelling: false, + age_ms: 600, + }; + let (nodes, _, _) = frames(Vec::new(), |ui| crate::panels::activity::bar(ui, &task, 200.0).rect.width()); + let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}")); + assert_eq!(bar.label, "Exporting"); + assert_eq!(bar.value, Some(12.0)); +} diff --git a/crates/ui-egui/src/tests_rating.rs b/crates/ui-egui/src/tests_rating.rs new file mode 100644 index 000000000..f89d1267b --- /dev/null +++ b/crates/ui-egui/src/tests_rating.rs @@ -0,0 +1,238 @@ +//! The star rating: clicking sets the rating, clicking the current one clears it, the row is one +//! Tab stop, the arrow keys step it, and screen readers hear every star. + +use egui::{Pos2, Rect, vec2}; + +use crate::rating::Rating; + +/// Draw `build` for a few frames. After the first, click the centre of star `click` (1 to 5) if given, +/// then press `keys`. Returns every value `build` reported and the AccessKit +/// (role, name) pairs. +fn drive(click: Option, keys: &[egui::Key], build: impl Fn(&mut egui::Ui) -> Option) -> (Vec, Vec<(String, String)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut reported = Vec::new(); + let mut nodes = Vec::new(); + let mut rects: Vec<(String, Rect)> = Vec::new(); + for frame in 0..6 { + let mut events = Vec::new(); + if frame == 1 + && let Some(n) = click + { + let want = format!("star:t:{n}"); + let at = rects.iter().find(|(id, _)| *id == want).map(|(_, r)| r.center()).unwrap_or(Pos2::ZERO); + events.push(egui::Event::PointerMoved(at)); + events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + // one key per frame from the third on, so Tab has moved the focus before an arrow arrives + if frame >= 2 + && let Some(k) = keys.get(frame - 2) + { + events.push(egui::Event::Key { key: *k, physical_key: Some(*k), pressed: true, repeat: false, modifiers: Default::default() }); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(v) = build(ui) { + reported.push(v); + } + }); + out.textures_delta.clear(); + let seen = crate::widgets::take_registry(&ctx); + if rects.is_empty() { + rects = seen; + } + if let Some(update) = out.platform_output.accesskit_update.take() { + nodes = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (reported, nodes) +} + +fn row(rating: u8) -> Rating { + Rating::new("t", rating) +} + +#[test] +fn clicking_a_star_sets_that_rating() { + let (got, _) = drive(Some(4), &[], |ui| row(2).show(ui)); + assert_eq!(got, vec![4]); +} + +#[test] +fn clicking_the_current_rating_clears_it() { + let (got, _) = drive(Some(3), &[], |ui| row(3).show(ui)); + assert_eq!(got, vec![0]); +} + +#[test] +fn hovering_alone_changes_nothing() { + // the preview is only painted; the rating is reported on click + let (got, _) = drive(None, &[], |ui| row(2).show(ui)); + assert!(got.is_empty()); +} + +/// How many stars `Rating::show` paints filled (in the star colour) after the pointer has moved to +/// the centre of star `hover` (1 to 5), if given. A filled star is ten triangles of one colour. +fn filled_stars(rating: u8, hover: Option) -> usize { + fn count(shape: &egui::Shape, colour: egui::Color32) -> usize { + match shape { + egui::Shape::Path(path) => usize::from(path.fill == colour), + egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count(shape, colour)).sum(), + _ => 0, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let colour = crate::theme::Tokens::get(&ctx).star; + let mut at = None; + let mut filled = 0; + for frame in 0..4 { + let mut events = Vec::new(); + if frame >= 1 + && let Some(at) = at + { + events.push(egui::Event::PointerMoved(at)); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = row(rating).show(ui); + }); + out.textures_delta.clear(); + let seen = crate::widgets::take_registry(&ctx); + if let (Some(n), true) = (hover, frame == 0) { + let want = format!("star:t:{n}"); + at = seen.iter().find(|(id, _)| *id == want).map(|(_, r)| r.center()); + assert!(at.is_some(), "star {n} is registered"); + } + filled = out.shapes.iter().map(|clipped| count(&clipped.shape, colour)).sum::() / 10; + } + filled +} + +#[test] +fn hovering_a_star_paints_the_rating_a_click_would_set() { + // the row shows 2 filled stars; the pointer over star 4 shows 4, and over star 1 shows 1 + assert_eq!(filled_stars(2, None), 2); + assert_eq!(filled_stars(2, Some(4)), 4); + assert_eq!(filled_stars(2, Some(1)), 1, "hovering a lower star previews the lower rating"); +} + +#[test] +fn a_screen_reader_hears_every_star_with_its_state() { + let (_, nodes) = drive(None, &[], |ui| row(2).show(ui)); + for n in 1..=5 { + let name = format!("{n} ★"); + assert!(nodes.iter().any(|(role, label)| role == "Button" && *label == name), "{name}: {nodes:?}"); + } +} + +#[test] +fn the_arrow_keys_step_the_rating_of_a_focused_star() { + // Tab focuses the first star; the row shows 3, so → asks for 4 and ← for 2 + let (got, _) = drive(None, &[egui::Key::Tab, egui::Key::ArrowRight], |ui| row(3).show(ui)); + assert_eq!(got, vec![4], "{got:?}"); + let (got, _) = drive(None, &[egui::Key::Tab, egui::Key::ArrowLeft], |ui| row(3).show(ui)); + assert_eq!(got, vec![2], "{got:?}"); +} + +#[test] +fn arrow_keys_do_nothing_when_no_star_is_focused() { + let (got, _) = drive(None, &[egui::Key::ArrowRight], |ui| row(3).show(ui)); + assert!(got.is_empty(), "{got:?}"); +} + +/// Feed the shown rating back each frame, with other focusable widgets on both sides. +fn step_with_neighbours(start: u8, keys: &[egui::Key]) -> Vec { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rating = std::cell::Cell::new(start); + let mut seen = Vec::new(); + // a key every frame, Tab then an arrow straight away + for frame in 0..keys.len() + 3 { + let mut events = Vec::new(); + if let Some(k) = frame.checked_sub(1).and_then(|f| keys.get(f)) { + events.push(egui::Event::Key { key: *k, physical_key: Some(*k), pressed: true, repeat: false, modifiers: Default::default() }); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| { + let _ = ui.button("left neighbour"); + if let Some(r) = row(rating.get()).show(ui) { + rating.set(r); + } + let _ = ui.button("right neighbour"); + }); + }); + out.textures_delta.clear(); + seen.push(rating.get()); + } + seen +} + +#[test] +fn arrows_keep_stepping_between_other_focusable_widgets() { + use egui::Key::*; + // Tab, Tab: the left neighbour, then star 1 + let seen = step_with_neighbours(5, &[Tab, Tab, ArrowLeft, ArrowLeft, ArrowLeft, ArrowLeft, ArrowLeft]); + assert_eq!(seen.last(), Some(&0), "{seen:?}"); + let seen = step_with_neighbours(0, &[Tab, Tab, ArrowRight, ArrowRight, ArrowRight, ArrowRight, ArrowRight, ArrowLeft]); + assert_eq!(seen.last(), Some(&4), "{seen:?}"); +} + +#[test] +fn a_rating_row_is_one_tab_stop() { + use egui::Key::Tab; + // left neighbour, one stop for the five stars, right neighbour: the third Tab is already past the row + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut focused = Vec::new(); + for frame in 0..5 { + let events = if frame >= 1 { + vec![egui::Event::Key { key: Tab, physical_key: Some(Tab), pressed: true, repeat: false, modifiers: Default::default() }] + } else { + Vec::new() + }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut right = None; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| { + let _ = ui.button("left neighbour"); + let _ = row(3).show(ui); + right = Some(ui.button("right neighbour").id); + }); + }); + out.textures_delta.clear(); + focused.push(right.is_some_and(|id| ctx.memory(|m| m.has_focus(id)))); + } + // frames 1 to 3 press Tab: left neighbour, the row, right neighbour + assert_eq!(focused, vec![false, false, false, true, false], "{focused:?}"); +} + +#[test] +fn a_rating_changed_from_outside_moves_the_focus_to_the_tab_stop() { + use egui::Key::Tab; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rating = std::cell::Cell::new(2u8); + let mut stars_focused = Vec::new(); + for frame in 0..5 { + // frame 1: Tab focuses the row's stop (star 2); frame 2: the rating changes elsewhere to 5 + if frame == 2 { + rating.set(5); + } + let events = if frame == 1 { + vec![egui::Event::Key { key: Tab, physical_key: Some(Tab), pressed: true, repeat: false, modifiers: Default::default() }] + } else { + Vec::new() + }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = row(rating.get()).show(ui); + stars_focused = (0..5).map(|i| ctx.memory(|m| m.has_focus(ui.id().with(("rating-star", "t", i as u8))))).collect(); + }); + out.textures_delta.clear(); + } + assert_eq!(stars_focused, vec![false, false, false, false, true], "{stars_focused:?}"); +} diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 24aa0d728..f4f54c3ad 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -16,9 +16,13 @@ const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); fn grid() -> Headless { + grid_sized([1000.0, 700.0]) +} + +fn grid_sized(size: [f32; 2]) -> Headless { let services = Services { png: None, ..Default::default() }; let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let mut h = Headless::new(app, [1000.0, 700.0], 1.0); + let mut h = Headless::new(app, size, 1.0); h.step(); h } @@ -237,8 +241,8 @@ fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { reveal(&mut h, "check:settings.slideshowRandom"); let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); assert_eq!(r["ok"], true, "{r}"); - h.step(); - assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback"); + // (waits for it: one frame isn't always enough on a loaded machine) + assert!(h.step_until(T, |h| h.app.session.slideshow.random_order), "the checkbox goes through slideshow.playback"); h.settle(SETTLE); } @@ -258,20 +262,35 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { h.settle(SETTLE); } -/// Scroll the Settings ▸ Slideshow column until widget `id` is in view (near its top): the -/// sections are taller than the dialog. +/// Scroll the Settings ▸ Slideshow page until widget `id` is in view (in the middle of the room +/// for the page): the sections are taller than the dialog. (The slide preview and its choice are pinned +/// above the page and are in view already.) fn reveal(h: &mut Headless, id: &str) { let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); for _ in 0..40 { h.step(); - let col = find(h, "slideshow:preview").expect("the Slideshow settings are open"); + let room = find(h, "settings:pageRoom").expect("the Slideshow settings are open"); + // (the preview and its Photo / Intro / Ending choice are pinned above the page: always in + // view, never scrolled to) + let pinned = id == "slideshow:preview" || id.starts_with("button:settingsSlideshowPreviewShows-"); + if pinned && find(h, id).is_some_and(|w| w.bottom() <= room.top()) { + return; + } let dy = match find(h, id) { - Some(w) if w.center().y >= col.top() + 10.0 && w.center().y <= col.top() + 340.0 => return, - Some(w) => (col.top() + 80.0 - w.center().y).clamp(-500.0, 500.0), + Some(w) if w.center().y >= room.top() + 10.0 && w.center().y <= room.bottom() - 10.0 => { + // (in view and still: the page eases to where it was scrolled to) + for _ in 0..3 { + h.step(); + } + if find(h, id).is_some_and(|now| (now.center().y - w.center().y).abs() < 0.5) { + return; + } + continue; + } + Some(w) => (room.center().y - w.center().y).clamp(-500.0, 500.0), None => -300.0, }; - // (the pointer over the settings column, left of the preview) - let r = h.request("ui.hoverWidget", json!({"id": "slideshow:preview", "fx": -1.0, "fy": 0.5}), T); + let r = h.request("ui.hoverWidget", json!({"id": "settings:pageRoom"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.scroll", json!({"dy": dy}), T); assert_eq!(r["ok"], true, "{r}"); @@ -373,9 +392,19 @@ fn settings_shows_the_slide_layout_and_a_live_preview() { run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"})); assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); h.step(); - let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); - let img = h.paint(); - let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + // (the preview is pinned above the page: in view already) + reveal(&mut h, "slideshow:preview"); + // (painted once the new preview is on screen: one frame isn't always enough on a loaded machine) + let t0 = std::time::Instant::now(); + let c = loop { + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + if (c[1] > 200 && c[0] < 60) || t0.elapsed() > T { + break c; + } + h.step(); + }; assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); h.settle(SETTLE); } @@ -592,6 +621,7 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { assert_eq!(crate::panels::slide_titles::preview_shows(&h.view.ctx), Some(lightcraft_engine::slideshow::Title::Intro)); run(&mut h, "slideshow.titles", json!({"intro": {"color": "#ff8000", "content": "none"}})); assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == PhotoId(0)))); + reveal(&mut h, "slideshow:preview"); assert!(h.step_until(SETTLE, |h| { let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); let img = h.paint(); @@ -606,6 +636,7 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { h.step(); assert!(has(&h, "field:slideshowIntroText")); // back to the photo's slide + reveal(&mut h, "button:settingsSlideshowPreviewShows-0"); let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowPreviewShows-0"}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); @@ -643,6 +674,8 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { let before = SlideLook::of(&h.app.session); let r = h.request("ui.hoverWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); + // (the pointer is over the row one frame after it moved there, and the page has settled the next) + h.step(); h.step(); assert_eq!(crate::panels::slide_templates::hovered(&h.view.ctx).as_deref(), Some("builtin:gallery-wall")); let grey = |h: &Headless| { @@ -655,16 +688,19 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); assert!(h.app.session.slide_templates_previous.is_none()); // a click applies it + reveal(&mut h, "template:builtin:gallery-wall"); let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); assert!(has(&h, "template:previous"), "Previous settings appear"); // save as a template of our own + reveal(&mut h, "field:slideshowTemplateName"); let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); assert_eq!(r["ok"], true, "{r}"); + reveal(&mut h, "button:slideshowTemplateSave"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -673,6 +709,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { assert!(has(&h, "template:user:Holiday _ 2026")); // update it from its menu with changed settings run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); + reveal(&mut h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -685,6 +722,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); // rename it in place + reveal(&mut h, "template:user:Holiday _ 2026"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -703,6 +741,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::>()); // delete it (after confirming) + reveal(&mut h, "template:user:Summer"); let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -716,6 +755,7 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.step(); assert!(!dir.join("Summer.json").exists()); // back to the settings before Gallery Wall + reveal(&mut h, "template:previous"); let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -723,3 +763,25 @@ fn template_browser_previews_on_hover_applies_and_manages_user_templates() { h.settle(SETTLE); let _ = std::fs::remove_dir_all(dir); } + +/// Hovering a template in the Template Browser previews it in the slide preview, so the preview +/// must be in view while the templates (further down the page) are: it is pinned above the page, +/// under the tab bar, and does not scroll away with it. +#[test] +fn the_slide_preview_stays_in_view_while_the_templates_are_browsed() { + // (900 x 560 is the least the desktop app's window is) + for size in [[1000.0, 700.0], [900.0, 560.0]] { + let mut h = grid_sized(size); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); + let tabs = find(&h, "button:settingsTab-slideshow").expect("the tab bar"); + // down to the last section of the page: the templates are far above the preview's old place + reveal(&mut h, "button:slideshowExportPdf"); + let preview = find(&h, "slideshow:preview").expect("the preview is still drawn"); + let room = find(&h, "settings:pageRoom").unwrap(); + assert!(preview.height() > 40.0, "{size:?}: {preview:?}"); + assert!(preview.top() >= tabs.bottom(), "{size:?}: under the tab bar, not scrolled up behind it: {preview:?} {tabs:?}"); + assert!(preview.bottom() <= room.top() + 1.0, "{size:?}: above the page, not in it: {preview:?} {room:?}"); + } +} diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 2ea310b04..3e1ce6e37 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -163,6 +163,10 @@ fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() { let mut app = app_in(&dir); let gone = dir.join("gone").to_string_lossy().into_owned(); app.ui.settings.recent_libraries = vec![gone.clone()]; + // the menu takes what a background check found (issue #1290 review): let it answer first + let ctx = egui::Context::default(); + crate::panels::library_problem::logic(&mut app, &ctx); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(20)), "the check answered"); let disabled = recent_submenu(&app) .iter() .any(|n| matches!(n, crate::menubar::MenuNode::Item { params, enabled: false, .. } if params["path"] == gone.as_str())); @@ -254,3 +258,22 @@ fn library_names_must_work_as_folder_names_on_every_system() { assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Trip 2026")); let _ = std::fs::remove_dir_all(&dir); } + +/// The web build's page title names the active photo (a host embedding the page reads it to know +/// which document a drop opened), with • while changes are not yet in storage (issue #2397). +#[test] +fn page_title_names_the_active_photo_and_unsaved_changes() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let ids = app.session.visible_cloned(); + let name = |app: &LightcraftApp, id: lightcraft_catalog::PhotoId| app.session.catalog.photo(id).unwrap().file_name.clone(); + app.run("library.select", json!({"ids": [ids[0].0]})).unwrap(); + assert_eq!(app.page_title(false), format!("{} — LightCraft", name(&app, ids[0]))); + assert_eq!(app.page_title(true), format!("{} • — LightCraft", name(&app, ids[0]))); + app.run("library.select", json!({"ids": [ids[1].0]})).unwrap(); + assert_eq!(app.page_title(false), format!("{} — LightCraft", name(&app, ids[1]))); + // no photo: the window title + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(empty.session.active().is_none()); + assert_eq!(empty.page_title(false), "LightCraft"); + assert_eq!(empty.page_title(true), "LightCraft •"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 57ee987d1..6c2bac6ac 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -44,6 +44,9 @@ pub struct Tokens { pub grid_bg: Color32, pub cell: Color32, pub cell_selected: Color32, + /// A thumbnail's place while its picture is still being made: static, apart from the grid, the + /// cells and the canvas in every theme. + pub skeleton: Color32, pub divider: Color32, pub inset: Color32, pub field: Color32, @@ -125,6 +128,7 @@ impl Default for Tokens { grid_bg: Color32::from_rgb(0x0f, 0x0f, 0x0f), cell: Color32::from_rgb(0x1c, 0x1c, 0x1c), cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d), + skeleton: Color32::from_rgb(0x25, 0x25, 0x25), divider: Color32::from_rgb(0x1c, 0x1c, 0x1c), inset: Color32::from_rgb(0x23, 0x23, 0x23), field: Color32::from_rgb(0x22, 0x22, 0x22), @@ -172,6 +176,7 @@ impl Tokens { grid_bg: rgb(0x080808), cell: rgb(0x121212), cell_selected: rgb(0x262626), + skeleton: rgb(0x1d1d1d), divider: rgb(0x0e0e0e), inset: rgb(0x181818), field: rgb(0x161616), @@ -189,6 +194,7 @@ impl Tokens { grid_bg: rgb(0xd9d9d9), cell: rgb(0xcbcbcb), cell_selected: rgb(0xb4b4b4), + skeleton: rgb(0xa6a6a6), divider: rgb(0xc4c4c4), inset: rgb(0xdcdcdc), field: rgb(0xf6f6f6), @@ -219,6 +225,7 @@ impl Tokens { grid_bg: rgb(0xf2f2f2), cell: rgb(0xe8e8e8), cell_selected: rgb(0xd2d2d2), + skeleton: rgb(0xbcbcbc), divider: rgb(0xe0e0e0), inset: rgb(0xf0f0f0), field: rgb(0xffffff), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2db3f885e..01d5b65f8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -776,41 +776,6 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: if !tooltip.is_empty() { resp.on_hover_text(tooltip) } else { resp } } -/// Star rating row (5 stars); returns a new rating when clicked (clicking the current rating clears). -pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let mut out = None; - let mut hover_n = None; - let mut rects = [Rect::NOTHING; 5]; - for (i, slot) in (0..5u8).zip(rects.iter_mut()) { - let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); - crate::widgets::announce_selected(&resp, format!("{} ★", i + 1), i < rating); - register(ui.ctx(), format!("star:{id}:{}", i + 1), r); - if resp.hovered() { - hover_n = Some(i + 1); - } - if resp.clicked() { - out = Some(if rating == i + 1 { 0 } else { i + 1 }); - } - *slot = r; - } - // Hover previews the rating a click would set: stars 1..=hovered light up. The hovered star also gets the - // icon_button hover square, so hovering the current rating (where the preview looks the same) still shows feedback. - for (i, r) in (0..5u8).zip(rects) { - if hover_n == Some(i + 1) { - ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); - } - let filled = star_lit(i, rating, hover_n); - paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); - } - out -} - -/// Whether the star at zero-based index `i` is drawn filled: the hovered star's rating previews over the current one. -fn star_lit(i: u8, rating: u8, hover: Option) -> bool { - i < hover.unwrap_or(rating) -} - /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { dropdown_within(ui, id, text, font, color, f32::INFINITY) @@ -849,16 +814,6 @@ mod tests { assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3")); } - /// Issue #647: hovering a star previews the rating a click would set. - #[test] - fn hovered_star_previews_its_rating() { - let lit = |rating, hover| (0..5u8).filter(|&i| star_lit(i, rating, hover)).count(); - assert_eq!(lit(2, None), 2, "no hover shows the rating"); - assert_eq!(lit(2, Some(4)), 4); - assert_eq!(lit(5, Some(1)), 1); - assert_eq!(lit(0, Some(3)), 3); - } - #[test] fn nudges_are_a_sensible_step() { let spec = |id, min, max, step| ControlSpec { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dd41bcdb8..7a8033aa5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -90,6 +90,7 @@ filmstrip is visible. | `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) | | `ui.resize` | `{width, height}` | Resize the window | +| `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:`, `compare:renderFailed:`) | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | @@ -165,7 +166,42 @@ catalog, a missing drive), the session starts empty and in memory — never with to do: `ui.inspect` → `libraryProblem` is `{path, error, temporarySession, pendingImport}` (else `null`); its buttons are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Continue Without Saving) and `button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and -writes nothing. `app.openLibrary` opening a library ends it. +writes nothing. `app.openLibrary` opening a library ends it. `pendingImport` counts the files waiting for a +library: those given on the command line, and photos dropped on the window while it (or the launch list below) is +up (desktop app); they are imported into the library that opens, or into the temporary session. Presets dropped +then are refused with a toast. + +### Choosing the library at launch + +With Settings ▸ General ▸ At launch set to Ask me (`ui.set {"settings": {"atLaunch": "ask"}}`; `"last"`, the +default, opens the last library), or Alt/Option held during the first 2 s after the window appears, the desktop app +shows a window listing the recent libraries (issue #1290). `--library` and `LIGHTCRAFT_LIBRARY` skip it, and so does +`--memory`; `lightcraft-cli` never shows it. `ui.inspect` → `libraryProblem.chooser` is `{libraries: [{path, +lastOpened, available, checking, reason}], selected, libraryOpen}` (else `null`; `libraryOpen: true` when it was opened +over the last library with Alt). Each library is checked read-only on a worker thread of its own, never on the UI +thread (the folder and its catalog file are looked at, never locked or written, so a library another program has +open shows as available and Open reports who has it in `libraryProblem.error`). `checking: true` until its check +answers (the row says *Checking…*, then that the drive isn't answering after 5 s); `available` is `false` only +once a check found a `reason`, so a row still checking can be opened. The selection starts on the last library and +moves to the first one that can be opened once every check has answered, unless it was moved meanwhile. Each row is the widget `libraryRecent:`; the buttons are `button:libraryOpen`, +`button:libraryOpenAnother` (the folder dialog of `app.openLibrary`), `button:libraryNew` (`app.newLibrary`), +`button:libraryForget` (Remove from List) and `button:libraryQuit`; `ui.key` ArrowUp / ArrowDown move the selection, +Enter opens it and Escape quits (over an open library: closes the window and keeps it). `app.openLibrary {path}` +opening a library ends it. While it shows, the settings are saved as usual (a removal is kept). + +`app.recentLibraries` returns `[{path, name, exists, checking, open, lastOpened}]`. `exists` is what the last +background check found (`false`: moved, on a disconnected drive, not a folder or no longer a library; `null`: not +checked yet, or the check couldn't tell, e.g. a folder that can't be listed, an unreadable catalog or a lookup that +failed) and `checking` whether one +is running: the command never looks at a folder itself. A failed open never marks a library missing (another program +may have it). Recent libraries are checked in the +background when the app starts (the first 8) and when the launch window opens; one not found is looked for again every +30 s (a drive plugged back in), the others only when the window comes back to the front (a timer would wake external +drives and shares). File ▸ Open Recent Library greys out only an entry a check found missing (not there, not a folder, +no library in it); an unreadable catalog, a folder that refuses to be listed, a failed open or a lookup that failed +(a share that timed out) doesn't grey it out. `lastOpened`: ISO 8601, `null` when not known; kept in `ui.json` → +`settings.recentLibrariesOpened`. `{remove: path}` forgets one entry (an error if the list +doesn't hold it) and `{clear: true}` (or `app.clearRecentLibraries`) all of them; nothing on disk is touched. ### Open Library on a Lightroom catalog @@ -173,12 +209,29 @@ writes nothing. `app.openLibrary` opening a library ends it. folder (it holds a `*.lrcat`), one of its `*.lrdata` / `*.lrcat-data` folders or `Backups/`, the `.lrcat` itself or its `-wal` / `-shm` / `.lock` files, it keeps the library open now and returns `{opened: false, lightroomCatalog: , path}`; the window shows a dialog whose `button:dialogOk` (Import Lightroom Catalog…, or -`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one) and -`button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always +`ui.dialog.confirm`) runs `file.importLightroom` with that catalog (no catalog found: it asks for one), which then +asks where the catalog goes when `settings.lightroomImport` is `"ask"` (next section), and `button:dialogCancel` (`ui.dialog.cancel`) does nothing. A folder that is a LightCraft library (`catalog.snap` / `catalog.log`) always opens as one. With no library open (the library problem window) it is an error instead. The engine refuses the same places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom {status: true}` reports the catalog's `path` after the task ends as well as while it runs. +### Import Lightroom Catalog: where the catalog goes + +`file.importLightroom {path?, into?, name?, location?}` (File ▸ Import Lightroom Catalog…, issue #2672). With `into` +it acts without asking: `"open"` imports into the library open now (`library.importLightroom`); `"new"` creates the +library `name` (default: the catalog's file name without `.lrcat`, or `Travel 2`… when taken) in the folder +`location` (a full path; default: the open library's parent folder) under `app.newLibrary`'s rules, opens it and +imports there, returning the import's start with `library: {path, name, photos}`. A catalog file that isn't there is +refused before anything is made; so is a new library while an import runs. Without `into`, a library on disk with +`settings.lightroomImport` `"ask"` (the default) opens a window and returns `{asking: true, path, into}` (`into`: the +choice it opens with, `"open"` when the library is empty, else `"new"`). Its widgets: +`radio:lightroomDestination-open`, `radio:lightroomDestination-new`, `field:lightroomLibraryName`, +`field:lightroomLibraryLocation`, `button:lightroomLibraryChoose` (where there is a folder dialog), `check:lightroomDontAsk` (with Merge only; sets +`"alwaysMerge"`), `button:dialogOk` / `ui.dialog.confirm` (which stays open on an error such as a folder that isn't +empty) and `button:dialogCancel`. With `"alwaysMerge"` (`ui.set {"settings": {"lightroomImport": "alwaysMerge"}}`, +or Settings ▸ General `button:settingsLightroomImport-0|1`), or in a session in memory, it imports into the open +library without asking. + ## External controllers External apps can use this channel to send commands and poll the running desktop app for feedback diff --git a/docs/focus-points.md b/docs/focus-points.md index 61c562b03..2171a7c15 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -6,7 +6,7 @@ zooms to it, so checking whether the subject is sharp takes one key while cullin ## Using it -- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in +- **View ▸ Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by default; the choice is saved with the app's settings. - **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in diff --git a/docs/gaps.md b/docs/gaps.md index 329dcf531..a8cdb5e5c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 28: ⌘L Enable Filters switches the filter off and on; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -23,11 +23,11 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | -| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | +| 9 B | **Tests don't run in CI on macOS or Windows**: Linux x64 runs `cargo xtask ci` on every PR and push to main (`.github/workflows/ci.yml`) and FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (ci, freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU; macOS / Windows jobs: #1148 | regressions in `cfg(target_os)` code reach macOS and Windows users | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | -| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result; before an import the app asks whether the catalog merges into the open library or goes into a new library (opt-out in Settings, #2672) | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | @@ -35,7 +35,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, ERF, KDC, DCR, MEF, GPR, old FinePix (MRW decodes since 2026-10-18) | raw-parity.md | small user share each | 40–80 | [raw-parity](raw-parity.md) | -| 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | +| 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping; exports add Thai glyphs only (#2052), with horizontal mark positioning still incomplete and Arabic fallback deferred until joining and bidi exist | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) | | 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | @@ -43,7 +43,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound; the grid-cycle key is shown beside both grid rows in View (#2500) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | | 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -84,15 +84,21 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | +| 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | +| 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | +| 2026-10-22 | minor | #2052: Thai glyphs added to shared export text; gap 21 retains Thai mark positioning, Arabic joining and bidi limits. No ranked gap closes; estimates unchanged | | 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged | | 2026-10-22 | patch | Issue #2519: PSD/PSB EXIF resource 1058 and XMP resource 1060 now use the common metadata extractor, and `.psb` files are accepted by import; no ranked gap or readiness count changes | | 2026-10-22 | minor | Gap 30 added: multi-select metadata editing — Info panel and All Metadata dialog show one photo's values with no mixed-value indication | | 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | | 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | +| 2026-10-19 | minor | Gap 9 shrinks: `.github/workflows/ci.yml` runs `cargo xtask ci` on Linux x64 for every PR and push to main; macOS and Windows remain | | 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | | 2026-10-19 | minor | Gap 27: the released 0.9.1 x64 MSI is tested on Windows 11 Pro 24H2 (a fresh install and an upgrade from 0.8.0 end on the completion page, so #790 is fixed; the upgrade keeps a custom folder; Launch starts the app; removal leaves nothing behind); uninstalling from Settings → Apps still ends with no confirmation (#892, #931); hours unchanged | | 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | +| 2026-10-14 | minor | Gap 4 follow-up: the diagnostic overlays (Visualize Spots, Point Color range, HDR range) render zoom windows too; soft proofing runs on the GPU | | 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged | | 2026-10-23 | minor | Gap 28: ⌘L / Ctrl+L (Library ▸ Enable Filters) puts the filter aside and brings it back as it was; the entry drops ⌘L; hours unchanged | | 2026-10-15 | minor | Gap 28: `\` shows / hides the Filter Bar in the grids; the entry drops `[` `]` (#509), flag cycling (#1490) and F1 (Help), already on main; ⌘L is the filter-bar key left; hours unchanged | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index c55e12965..f88f81c39 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -4,6 +4,10 @@ The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). `lightcraft-gp evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in. +CPU fast guided-filter allocation measurements and reproduction commands are documented in +[guided-filter-memory.md](guided-filter-memory.md). That optimization retains the full-size base +and does not change the GPU's coefficient buffers. + Warped geometry uses a CPU-computed coverage bit mask for the source boundary. The CPU reference evaluates that boundary in double precision; GPU float rounding otherwise can turn a blank edge pixel into a photo pixel. The mask @@ -101,7 +105,8 @@ or worked around with `LIGHTCRAFT_GPU_BACKEND`. **The window's renderer and its fallback (Windows).** Before the window opens, each renderer is probed in order: DX12, Vulkan, GL, then eframe's OpenGL renderer (glow). A probe creates a device and builds egui's pipeline inside wgpu error scopes, so a driver that cannot compile egui's shaders ("The parameter is -incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app. The renderer +incorrect. (0x80070057)" in `egui_pipeline`) is logged and skipped instead of killing the app; no pop-up +unless that renderer was forced (#1624). The renderer that worked is remembered in the settings folder (`window-renderer`); a start that dies before the window is up leaves `window-init.marker`, and the next start (relaunched automatically, at most 4 times) tries that renderer last. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | gl | glow` (or `WGPU_BACKEND`) forces one; DX12 stays @@ -161,6 +166,8 @@ walks the drive. (log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight sampling), `mask` (linear / radial / luminance / colour range / brush shapes, combine, finalize), `finish` (the whole per-pixel stage) — each mirrors a named CPU function. +- Soft proofing against an output space (`RenderRequest::proof`, with its destination and display gamut + warnings) runs in `finish`; a proof against an output-device profile (its 3D table) renders on the CPU. - Per-stage hybrid: defringe and spot removal (rare, CPU-only for now) download the resampled image, run on the CPU and upload; heuristic mask shapes (Sky, Subject, Background, …) and brushes of more than 4096 dabs are evaluated on the CPU and uploaded; sources over the buffer limit are resampled @@ -260,6 +267,13 @@ Remaining differences come from f32 vs f64 coordinate math, fast-math transcende Metal and summation order in the box filters — all far below one 8-bit step. ## Measurements +**In the app:** Help ▸ Debug ▸ Frame Rate (`view.frameRate`, `{show?}`; `LIGHTCRAFT_FPS=1` +starts with it on, never saved) draws frames per second, each frame's UI CPU time against its +time spent waiting (for vsync, or for the GPU), the latest whole-frame and zoom-window render +times, the render queue, renders thrown away as superseded, the GPU adapter and its memory, and a +graph of the last frames with a tick where a render arrived. Once a second it logs the same +numbers (`fps-hud:`), so a scripted drag (`ui.dragWidget`) can be measured from the log. + `render_bench` on `corpus/raw/arw-sony-a7m3-compressed.arw` (24 MP; loupe = 2560 px preview → 1920×1280), min of 7 runs, wall clock, on a heavily shared machine (load average 60–110 on 14 cores, so CPU numbers are pessimistic; the GPU numbers are less affected): diff --git a/docs/guided-filter-memory.md b/docs/guided-filter-memory.md new file mode 100644 index 000000000..de0fe1b62 --- /dev/null +++ b/docs/guided-filter-memory.md @@ -0,0 +1,53 @@ +# CPU fast guided-filter memory + +`local::guided_fast` computes blurred coefficients on the same reduced grid as before, then +samples them while applying `a * p + b`. It keeps the raster resizer's weights, boundary +normalization and horizontal-then-vertical accumulation order. Odd dimensions therefore use +their actual reduced/full dimension ratios. Four coefficient row pairs and one bias row are cached +per active 32-row band; adjacent output rows reuse their horizontal samples. + +The two full-resolution coefficient planes and their resize intermediates are gone. The returned +full-resolution base remains: downstream CPU tone mapping and the spatial-plane cache need it. +The step-1 filter, Box reduction, Gaussian coefficients, V2's median input, process selection and +Draft sigma cap are unchanged. The GPU implementation is unchanged; this reduces CPU memory only. + +## Reproduce + +The dev-only example includes the previous materialized algorithm and uses the workspace's +existing `dhat` allocator. No allocator or profiling dependency is added to product builds. + +```sh +cargo build --release -p lightcraft-pipeline --example guided_memory +RAYON_NUM_THREADS=1 target/release/examples/guided_memory old 6000 4000 120 15 +RAYON_NUM_THREADS=1 target/release/examples/guided_memory new 6000 4000 120 15 +RAYON_NUM_THREADS=1 target/release/examples/guided_memory old 6000 4000 120 1 --heap +RAYON_NUM_THREADS=1 target/release/examples/guided_memory new 6000 4000 120 1 --heap +``` + +If `CARGO_TARGET_DIR` is set, use that directory instead of `target` for the executable. +Arguments are mode, width, height, integer sigma and timing iterations. The example warms up +before measurement; `--heap` counts one filter call with the source allocated beforehand. +Time the runs without `--heap`: allocation profiling significantly changes timings. It reports +the minimum wall time; on a shared machine repeat when other builds/renders have stopped. +Use `LIGHTCRAFT_PROFILE=1` and `cargo xtask bench` for broader pipeline measurements. + +## Observed allocation and time + +Measured 2026-10-16 UTC, macOS aarch64, Rust 1.96.0, release build, one Rayon thread. Bytes are +requested heap bytes counted by dhat, not process RSS. Timings are minimums of 15 unprofiled +calls on a shared machine with concurrent builds; they are indicative, not a speed guarantee. + +| Dimensions / sigma / step | Old peak bytes | New peak bytes | Old total allocated bytes | New total allocated bytes | Old / new wall ms | +|---|---:|---:|---:|---:|---:| +| 6000 × 4000 / 120 / 16 | 289,125,000 | 97,780,856 | 313,083,488 | 135,643,856 | 42.135 / 45.131 | +| 1601 × 1067 / 24 / 8 | 20,822,412 | 7,331,172 | 25,102,604 | 11,562,560 | 3.359 / 3.121 | + +The 24 MP result saves about 191 MB of peak allocation (66%). Its **96,000,000-byte output is +included**; its **96,000,000-byte input is excluded**. Application RGB images, log luminance, +V2 median input and its construction temporaries, other spatial planes and caches also remain +outside this isolated measurement. More Rayon threads can hold more band scratch concurrently. +This is not a bound on total pipeline memory. + +Regression tests compare the sampled result exactly against materialized bilinear resizes for +empty/singleton/one-dimensional, odd/even and multiple-band images; flat fields, signed texture, +edges and impulses; small/full and reduced filters; and median tone bases. diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 441becd15..cd5df6b85 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -6,7 +6,41 @@ and a C SQLite runtime are not required. The source database is read-only, inclu File → Open Library… given a Lightroom catalog folder (or an `.lrdata` / `.lrcat-data` folder, or the `.lrcat`) doesn't make a LightCraft library there: it offers this import instead, with the catalog found -(`crates/engine/src/lightroom_folder.rs`, issue #891). `lightcraft-cli --library` refuses those places too. +(`crates/engine/src/lightroom_folder.rs`, issue #891), and then asks where the catalog goes as described next +(unless the question is turned off, below). +`lightcraft-cli --library` refuses those places too. + +**Where the catalog goes.** Before the import starts, the desktop app asks where the catalog goes (issue #2672), +unless Settings says to always merge (*Turning the question off*, below): + +- **Merge into the open library "" (N photos)**: the catalog's photos, collections and edits join the ones + already in the library open now (the library's name is its folder's name). +- **Create a new library**: named after the catalog file (`Travel.lrcat` → "Travel", or "Travel 2" when a + "Travel" folder is already there) and placed next to the open library (in its parent folder); both can be + changed (Choose… picks the folder). LightCraft creates and opens it as File → New Library… does (the folder + must be new or empty; it is refused while another import runs), then imports the catalog into it. The library + left behind stays in File → Open Recent Library. +- **Cancel** does nothing. + +The window opens on *Merge* when the open library has no photos (there is nothing to merge with) and on *Create a +new library* when it has some. + +**Turning the question off.** Settings → General → Library → *When importing a Lightroom catalog*: *Ask where to +import it* (the default) or *Always merge into the open library* (how imports always worked before). The window's +*Don't ask again (always merge into the open library)* checkbox, offered with *Merge*, picks the second; the window +says where to turn the question back on. The choice is an app setting (`ui.json`, `settings.lightroomImport`: +`"ask"` or `"alwaysMerge"`); a settings file without it asks. + +A session with no library on disk (in memory, e.g. after Continue Without Saving) has nothing to put a new library +next to: the catalog merges into it without asking, as before. + +**Scripts and agents.** The app command `file.importLightroom {path?, into?, name?, location?}` (control channel, +`lightcraft-cli mcp --connect`) chooses without the window: `into: "open"` merges, `into: "new"` creates the library +`name` (default: the catalog's name) in the folder `location` (default: the open library's parent folder; a full +path), opens it and imports. Without `into` it shows the window (and returns `{asking: true, path, into}`, `into` +being the choice the window opens with) or merges when the setting says so. A catalog path that doesn't exist is +refused before a new library is made. The engine command `library.importLightroom` always imports into the library +open now. Equivalent commands: @@ -15,8 +49,8 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path= lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat" ``` -Import adds records to the currently open LightCraft library; it does not create a separate -folder named after the Lightroom catalog. The desktop result stays open until dismissed and +The import itself (`library.importLightroom`, `lightcraft-cli run`) adds records to the currently open LightCraft +library; only the desktop window above can make a new library for it first. The desktop result stays open until dismissed and shows the destination library, the number of records added, missing originals and warnings. **Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 41669e4b7..a2bb2833a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -6,7 +6,7 @@ How far each interface language is from `full`. How to add or maintain a languag [localization.md](localization.md); per-language notes are in `localization-.md` ([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md), [pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md), -[ru](localization-ru.md), [fi](localization-fi.md), [ka](localization-ka.md), [id](localization-id.md), [vi](localization-vi.md)). +[ru](localization-ru.md), [fi](localization-fi.md), [ka](localization-ka.md), [id](localization-id.md), [vi](localization-vi.md), [pl](localization-pl.md)). ## The target @@ -62,7 +62,7 @@ string follows that. | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 2,471 / 2,471 (100% key coverage of current union; intentional English like `ISO`/`OK` kept) | menus, panels, dialogs, presets, profiles, dates, formats; lower-layer errors English | Latin with stacked diacritics (Inter) | no (community contribution) | partial (catalog complete; lower-layer errors English) | 1–3 h | -## Other languages LightCraft ships (4) +## Other languages LightCraft ships (7) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| @@ -72,8 +72,9 @@ string follows that. | Italian | `it` | 2,628 / 3,278 (80%, measured 2026-10-15; every interface string is translated, the other 650 are What's New lines, left in English) | Latin (Inter) | partly (written with an agent; corrections and the 2026-10-15 additions by a native speaker) | partial (lower-layer errors English) | 3–5 h | | Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) | | Finnish | `fi` | 2,610 / 3,308 (79%, measured 2026-10-17: 2,313 plain + 297 formats; the rest is What's New) | Latin (Inter) | yes (written with an agent; native-speaker review by @roopeg) | partial (lower-layer errors English) | 3–5 h | +| Polish | `pl` | 2,159 messages + 291 formats; every format message translated (2026-10-15) | Latin (Inter) | no (AI-drafted; follow-up by a native speaker) | partial (lower-layer errors English) | 2–4 h (full native review) | -Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +Target languages LightCraft lacks: Korean, Dutch, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai ~20–35 h on top of the shaping work. @@ -90,9 +91,9 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok ## Totals -- Bring the 10 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Bring the shipped non-English catalogs to `full` (17 on 2026-10-23; the estimate was made for 10): ~55–90 h (incl. the shared work). - Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. -- Reach the target's 16 languages: + ~50–85 h (Dutch, Polish, Swedish, Norwegian, Thai). +- Reach the target's 16 languages: + ~30–55 h (Dutch, Norwegian, Thai; Polish and Swedish ship). ## Revision history @@ -100,6 +101,7 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok |---|---|---| | 2026-10-19 | minor | Indonesian interface catalog (`id`): every plain and formats message any catalog has, plus 69 command labels; What's New left in English | | 2026-10-17 | minor | Finnish interface catalog (`fi`): 2,610 of 3,308 messages (2,313 plain + 297 formats), What's New left in English | +| 2026-10-15 | minor | Polish row added: the format messages still in English translated, counts made right for every number, dates day.month.year ([localization-pl.md](localization-pl.md)) | | 2026-10-15 | trivial | Italian (`it`): 339 interface messages and 60 format strings that were missing or still in English translated (2,628 of 3,278; only What's New left in English); native-speaker corrections: Exposure → *Esposizione*, Sharpening → *Nitidezza*, Preset → *Preset*, blurry → *sfocate* | | 2026-10-13 | minor | Remeasured denominator (2,222 plain + 249 formats = 2,471); Vietnamese catalog complete at 2,471/2,471 (status partial: lower-layer errors still English) | | 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | diff --git a/docs/localization-pl.md b/docs/localization-pl.md new file mode 100644 index 000000000..445dbcf63 --- /dev/null +++ b/docs/localization-pl.md @@ -0,0 +1,56 @@ +# Polski + +Język polski (`pl`, English name **Polish**, nazwa natywna **Polski**) włącza się przez **Edytuj → Język → +Polski** albo **Ustawienia → Ogólne → Język**. Zmiana działa od razu i jest zapamiętywana przy kolejnym +uruchomieniu. Polecenie to `app.language.polish`; kanał sterowania przyjmuje `ui.set {"language":"pl"}` +oraz znaczniki `pl-PL` i `pl_PL.UTF-8`. Bez okna można ustawić `LIGHTCRAFT_LANGUAGE=pl`. + +Katalogi `crates/ui-egui/locales/pl.json` i `pl-formats.json` dodał #1100 (tłumaczenie przygotowane +z pomocą AI). Uzupełnienie opisane niżej również przygotowano z pomocą AI; przygotował je rodzimy +użytkownik języka polskiego (@lewandowskit). Profesjonalny tłumacz nie robił jeszcze korekty. + +## Konwencje + +- Menu: Plik, Edytuj, Widok, Zdjęcie, Okno, Pomoc. +- Library = Biblioteka, Develop = Wywoływanie, Preset = Ustawienie wstępne, Stack = Stos, + Masking = Maskowanie, Virtual Copy = Kopia wirtualna, Filmstrip = Pasek zdjęć. +- Clarity = Przejrzystość, Dehaze = Usuń zamglenie, Vibrance = Dynamika, Saturation = Nasycenie. +- Highlights = Światła, Shadows = Cienie, Whites = Biele, Blacks = Czernie. +- Cudzysłowy polskie: „…”. Ścieżki menu w tekstach: `Edytuj → Cofnij`. +- Data ma postać `20.09.2026` (dzień.miesiąc.rok), nagłówek dnia `Niedziela, 20.09.2026`, + miesiąca `09.2026`; czas 24-godzinny (`16:04:05`). +- **Liczby i rzeczowniki.** Polski ma trzy formy liczby mnogiej (1 zdjęcie, 2 zdjęcia, 5 zdjęć), + a mechanizm katalogów rozróżnia tylko „jeden” i „wiele”. Dlatego liczba stoi po rzeczowniku: + „Zaimportowane zdjęcia: 22”, „Usuń zdjęcia (3)”, „Oczekujące zdjęcia: 2”. Taka forma jest + poprawna dla każdej liczby. Angielska końcówka liczby mnogiej jest pochłaniana przez `{:.0}` + (jak w [localization.md](localization.md)). Po przyimku z dopełniaczem („z 10 zdjęć”, „dla 3 + zaznaczonych zdjęć”) liczba może stać przed rzeczownikiem, bo forma jest wtedy zawsze ta sama. +- Komunikaty `{n} {noun}` (np. w panelu Odszumianie AI) mają postać `{noun}: {n}`, a obie formy + rzeczownika (`photo waiting` / `photos waiting`) tłumaczą się tak samo. +- Font Inter dołączony do aplikacji zawiera wszystkie polskie litery i cudzysłowy; inny font + nie jest potrzebny. + +## Uzupełnienie (2026-10-15) + +- Przetłumaczono 40 komunikatów `pl-formats.json`, które były jeszcze po angielsku (ustawienia + efektów masek, szablony pokazu slajdów, import katalogu Lightroom, słowa kluczowe, osoby, + scalanie panoram, komunikat o nieoczekiwanym zakończeniu programu) i 2, w których zmieniono + tylko strzałkę. +- Poprawiono 25 komunikatów, w których liczba stała przed rzeczownikiem w dopełniaczu i dla + 2–4, 22–24… dawała błędną formę („Usuń 2 zdjęć”), oraz `{} failed`, gdzie `{}` jest nazwą + zadania, a nie liczbą. +- Daty: `{month} {year}` i `{weekday}, {day} {month} {year}` mają postać dzień.miesiąc.rok + zamiast „20 9 2026”. + +Katalog ma 2 159 zwykłych komunikatów i 291 szablonów formatów. Komunikaty, których nie ma w +katalogu, oraz techniczne błędy niższych warstw wyświetlają się po angielsku. + +## Weryfikacja + +Test `i18n::tests::polish_switches_formats_dates_and_reads_every_count` sprawdza przełączanie +z menu i kanału sterowania, zapamiętanie języka, znaczniki `pl-*`, daty, czas, liczby 0–111 +(w tym 2–4 i 22–25) w liczonych komunikatach, nazwy użytkownika pozostawione bez zmian oraz +polskie znaki w fontach interfejsu. Zgodność placeholderów sprawdza +`catalogs_agree_on_placeholders_and_report_gaps`. + +Postęp dodawania języków opisuje [localization.md](localization.md). diff --git a/docs/localization.md b/docs/localization.md index 5b4cbc15d..b878a5de2 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,11 +2,11 @@ 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), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`), Georgian (`ka`), Swedish (`sv`), Finnish (`fi`), Indonesian (`id`) and Vietnamese (`vi`). This file is the reference for **adding or maintaining a language**; the per-language notes +Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`), Italian (`it`), Georgian (`ka`), Swedish (`sv`), Finnish (`fi`), Indonesian (`id`), Vietnamese (`vi`) and Polish (`pl`). 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), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md), [`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md), -[`localization-cs.md`](localization-cs.md), [`localization-fi.md`](localization-fi.md), [`localization-ka.md`](localization-ka.md), [`localization-id.md`](localization-id.md) and [`localization-vi.md`](localization-vi.md). +[`localization-cs.md`](localization-cs.md), [`localization-fi.md`](localization-fi.md), [`localization-ka.md`](localization-ka.md), [`localization-id.md`](localization-id.md), [`localization-vi.md`](localization-vi.md) and [`localization-pl.md`](localization-pl.md). ## Adding a language diff --git a/docs/mcp.md b/docs/mcp.md index 31e069de9..4800c37d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,7 +170,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now | | `run_command {command, params?}` | Run any command by id | -| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | +| `import {paths, album?, mode?, destination?, organize?, rename?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active. `mode: "copy"` copies into `destination` (default: the library's Originals/), `mode: "move"` moves there (each original and its XMP sidecars are removed from the source only after the copy is verified and catalogued; duplicates and failures keep their sources; the result's `moved` / `kept` list what moved and what stayed, and why; undo leaves the moved files at the destination), filed by `organize`: `date` (YYYY/YYYY-MM-DD), `month`, `flat` or a folder template such as `{date:%Y}/{date:%Y%m%d}` (→ `2026/20260114`) or `{date:%Y}/{date:%B}` (→ `2026/January`; capture date, else the import date; always inside the destination), and named by the `rename` template (`run_command photo.renameTokens` lists the tags) | | `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | | `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | diff --git a/docs/merge.md b/docs/merge.md index ce9c71beb..e2183583a 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -8,7 +8,8 @@ All algorithms are our own implementations from published papers (cited in the m - **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews - the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge + the merge at ≤ 1024 px and re-runs the preview when an option changes; as in Lightroom, a click on the preview + zooms in on that point (drag to pan, click again to fit), and it stays there while options change; *Merge* runs the full merge in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. *Photo Merge ▸ HDR for Each Bracket Set* merges many brackets in one go with the last HDR options: one HDR per stack among the selected photos, then per diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..7fa952efb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (LR-LIB-RATING, LR-LIB-DELETE: an open slider drag ends before the selection moves on; LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… asks whether the catalog merges into the open library or goes into a new library, with an opt-out in Settings ▸ General, #2672; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; export regression integration; LRC-PRINT-LAYOUT: Japanese photo and postcard paper sizes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -44,7 +44,7 @@ cited here still exists. Formerly docs/parity.md. | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 15 | 4 | 0 | 0 | 7/7 (100%) | 7/7 (100%) | +| S. Export (EXP) | 14 | 5 | 0 | 0 | 7/7 (100%) | 6/7 (86%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 25 | 21 | 8 | — | 24/26 (92%) | -| **Total** | 421 | 64 | 57 | 35 | 196/203 (97%) | 147/161 (91%) | +| **Total** | 420 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.7% of 161 · P2 56.5% of 177. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.5% of 177. ## Top gaps and shortcut notes @@ -70,7 +70,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | -| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | +| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests_drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | @@ -100,7 +100,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. A rating, flag or label that takes the active photo out of the view (filter bar, Picks, smart album) makes the photo after it active (the one before at the end), and Shift / Auto Advance doesn't step past it (#2312). Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | @@ -111,10 +111,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | -| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. | +| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. A capture time that doesn't fully read (cut short, or text after it) is left as it is and counted (`unreadable`); it can't anchor a set that moves other photos, and set alone repairs it. | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | -| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | +| LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s, both limits adjustable in the dialog and as `burstGap` / `similarity`, #2275) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | | LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `cmd:photo.delete`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; deleting a copy dissolves or renumbers the stack (#1493); no “Set Copy as Master” | @@ -129,13 +129,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); in Detail a middle-button drag (the wheel pressed) pans with a hand cursor whatever tool is active, so Remove / Heal / Clone, mask brushes and Crop don't paint or move meanwhile (`middle_pan`, `tests_canvas_pan.rs`); Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with an output-device profile proof or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) and the size the fit view is shrunk to until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; only an output-device-profile proof renders on the CPU, where a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with an output-device-profile soft proof (the CPU path) | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions` | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | @@ -198,7 +198,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); a wheel drag holds the pointer as in Lightroom: hidden while dragging (the puck follows the mouse's travel; on macOS the pointer is locked in place), back where the button went down on release, and given back at once on Esc, a lost window focus or a wheel that goes away mid-drag; the browser can't move the pointer, so there it only shows again where the drag ended (issue #1899; tests `a_held_wheel_follows_the_mouses_motion_not_where_the_pointer_sits`, `a_wheel_drag_hides_the_pointer_and_releasing_puts_it_back_where_it_was_pressed`, `escape_or_losing_focus_gives_the_pointer_back_mid_drag`, `a_wheel_that_goes_away_mid_drag_gives_the_pointer_back`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | @@ -248,7 +248,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A, its own row in Keyboard Shortcuts: `crop.lockAspect` toggles it, sharing A with Visualize Spots by context), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | @@ -321,7 +321,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; the preset applied to a photo stays marked on returning to it until the photo is edited further (issue #2392); resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | -| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); no hiding of groups | +| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests_preset_favorites.rs`); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | | LR-PRE-PREMIUM | Built-in presets (own) | P2 | ✅ | `crates/engine/src/presets.rs` | 41 own-authored presets in 10 groups (Color, Film, B&W incl. toners, Portrait, Landscape, Urban, Food, Seasons, Vintage, Style) | @@ -352,7 +352,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, click-to-zoom preview (drag pans; the zoom stays while options change), auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | @@ -392,14 +392,14 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | -| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | +| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | @@ -429,7 +429,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-