Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb8ad4dcd | ||
|
|
dc67d348e6 | ||
|
|
7f386aa258 | ||
|
|
399aedd23a | ||
|
|
b5fcf58e55 | ||
|
|
261994acbc | ||
|
|
b936c1a683 | ||
|
|
cab6af8608 | ||
|
|
4eb9b565e0 | ||
|
|
ccdd9e45d1 | ||
|
|
24c800f46f | ||
|
|
e471e7c346 | ||
|
|
aaec05dd11 | ||
|
|
d808749792 | ||
|
|
c03a67c59f | ||
|
|
cd8d2b96a2 | ||
|
|
a12fabd588 | ||
|
|
99fa19099b | ||
|
|
bac4666e7f | ||
|
|
b7f60a1fac | ||
|
|
4d506494d9 | ||
|
|
90fe6d1616 | ||
|
|
fa630a233a | ||
|
|
9e9e45b3c7 | ||
|
|
259de3d9df | ||
|
|
53584512f4 | ||
|
|
e4737f68c7 | ||
|
|
21de86b3df | ||
|
|
0b101be155 | ||
|
|
7d2bb5e5ec | ||
|
|
3a9fe13d3a | ||
|
|
2565f893c4 | ||
|
|
4e2584e559 | ||
|
|
cb173c22dc | ||
|
|
98da2a0229 | ||
|
|
1ece9341d6 | ||
|
|
6d52e0744a | ||
|
|
dd783b1d38 | ||
|
|
81e28f25c9 | ||
|
|
b5b6f8caa8 | ||
|
|
7e21d185b8 | ||
|
|
70db2f2320 | ||
|
|
74b9129a05 | ||
|
|
36c6920f55 | ||
|
|
6d22d1235b | ||
|
|
5f9548a7ba | ||
|
|
0e188f6a40 | ||
|
|
a66eebf778 | ||
|
|
e3dc820d89 | ||
|
|
a6cedcc9e8 | ||
|
|
493600743d | ||
|
|
e4ab8422b4 | ||
|
|
0b5ef3fe11 | ||
|
|
38d2ff07c9 | ||
|
|
6c19090fde | ||
|
|
5b80fc6bcf | ||
|
|
0a474cc06e | ||
|
|
2928880e81 | ||
|
|
14a03052de | ||
|
|
7f6e4e48f7 | ||
|
|
912f56cac8 | ||
|
|
0e254bfbc6 | ||
|
|
8d8178dc0a | ||
|
|
90c63af951 | ||
|
|
69640df8a7 | ||
|
|
a47afdc1f4 | ||
|
|
b4a192a54e | ||
|
|
4256865cf4 | ||
|
|
57c9e252c1 | ||
|
|
46c4a2709d | ||
|
|
198286e589 | ||
|
|
6f33aacd22 | ||
|
|
5d13d59520 | ||
|
|
0732edc64f | ||
|
|
82e827b9d9 | ||
|
|
88c1e768f1 | ||
|
|
cc425d0cea | ||
|
|
6277af55bf | ||
|
|
30f9eb3674 | ||
|
|
7177b9b9f3 | ||
|
|
c60482f753 | ||
|
|
c1861e5416 | ||
|
|
88d9b7196d | ||
|
|
47f40eb2d5 | ||
|
|
39e04ae7ab | ||
|
|
ee0b0ef67e | ||
|
|
169a4ed059 | ||
|
|
f7c0b9e49f | ||
|
|
b7e37a388d | ||
|
|
87dd1c201a | ||
|
|
15feb6e1fc | ||
|
|
03bc1469f9 | ||
|
|
4e012a59a6 | ||
|
|
8dd3afce51 | ||
|
|
4b57d5dc5a | ||
|
|
fc4b054fa4 | ||
|
|
d614dc72bd | ||
|
|
024e983a7e | ||
|
|
1ba0421a85 |
No files matched your search
Generated
+1
@@ -2876,6 +2876,7 @@ name = "lightcraft-engine"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"harfrust",
|
||||
"libc",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-codecs",
|
||||
|
||||
@@ -312,7 +312,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
|
||||
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
|
||||
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
|
||||
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
|
||||
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
|
||||
| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) |
|
||||
@@ -356,6 +356,19 @@ repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftru
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report. Each start moves the
|
||||
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
|
||||
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
|
||||
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
|
||||
at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and
|
||||
errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces
|
||||
the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,lightcraft_pipeline=trace` (a
|
||||
directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there
|
||||
too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/lightcraft/src/logging.rs`.
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
|
||||
+10
-6
@@ -10,7 +10,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
|
||||
**In one line:** the checklist says **79%** (P0 98.5%, P1 95.9%, P2 39%), but measured by whether a working
|
||||
**In one line:** the checklist says **79.7%** (P0 98.2%, P1 95.6%, P2 41.6%), but measured by whether a working
|
||||
photographer could replace Lightroom without noticing, we are at roughly **60–70%**. The remaining gap is mostly
|
||||
**quality of results and camera coverage**, not missing buttons.
|
||||
|
||||
@@ -22,7 +22,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|
||||
| Dimension | Estimate | What's true today | Biggest gaps |
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
@@ -67,6 +67,10 @@ Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29
|
||||
Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export
|
||||
hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32).
|
||||
|
||||
Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views.
|
||||
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
|
||||
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
|
||||
|
||||
## Milestones
|
||||
|
||||
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
|
||||
@@ -98,10 +102,10 @@ parity` prints this line on every run, so it stays current):
|
||||
|
||||
| Scope | Weighted completion | Rows | 2026-10-02 |
|
||||
|---|---:|---:|---:|
|
||||
| P0 (core) | **98.5%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.9%** | 147 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **39.2%** | 158 | 29.4% |
|
||||
| **All in-scope rows** | **79.2%** | 505 | 75.6% |
|
||||
| P0 (core) | **98.2%** | 200 | 98.7% |
|
||||
| P1 (important parity) | **95.6%** | 149 | 94.4% |
|
||||
| P2 (later / AI / niche, incl. Classic modules) | **41.6%** | 160 | 29.4% |
|
||||
| **All in-scope rows** | **79.7%** | 509 | 75.6% |
|
||||
|
||||
The 2026-10-05 count includes three new 🟡 rows that make quality gaps visible (camera colour calibration, verified
|
||||
camera coverage, render fidelity), which is why P0 dipped slightly.
|
||||
|
||||
@@ -71,6 +71,8 @@ USAGE:
|
||||
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
|
||||
Replies go to stdout. `ui.screenshot` without a path writes -o (then
|
||||
OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders.
|
||||
A failed request (\"ok\": false) does not stop the script, but the exit
|
||||
status is non-zero when any request failed.
|
||||
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
|
||||
--size WxH window size in points (default 1600x1000)
|
||||
--scale S pixels per point (default 1)
|
||||
@@ -122,6 +124,9 @@ fn main() -> ExitCode {
|
||||
dhat::Profiler::builder().file_name(file).build()
|
||||
};
|
||||
alloc_release::install();
|
||||
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
|
||||
// or RUST_LOG picks another level (#168).
|
||||
lightcraft_engine::logging::install("lightcraft-cli");
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => run(&args[1..]),
|
||||
@@ -659,7 +664,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
for (k, v) in opts {
|
||||
p[k] = v;
|
||||
}
|
||||
let mut o = ExportOptions::from_json(&p);
|
||||
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
|
||||
if p.get("format").is_none() {
|
||||
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
o.format = ExportFormat::parse(&ext)
|
||||
@@ -755,6 +760,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string())
|
||||
};
|
||||
let mut wrote_output = false;
|
||||
// Issue #166: a scripted request that fails still gets its reply printed and the run carries
|
||||
// on (like `run --keep-going`), but the exit status reports it — CI judges by exit status.
|
||||
let mut failed = 0usize;
|
||||
if let Some(script) = &script {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
@@ -787,6 +795,9 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
_ => h.request(&method, params, timeout),
|
||||
};
|
||||
if reply["ok"] != true {
|
||||
failed += 1;
|
||||
}
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
// wall time of the request (incl. the frames it ran), and since the start
|
||||
@@ -823,7 +834,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
|
||||
Ok(())
|
||||
if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn commands(args: &[String]) -> Result<(), String> {
|
||||
|
||||
@@ -183,6 +183,39 @@ fn snapshot_subcommand_renders_the_ui_headlessly() {
|
||||
assert_eq!((d.width, d.height), (640, 480));
|
||||
}
|
||||
|
||||
/// Issue #166: a scripted request that fails makes `snapshot` exit non-zero. The failed reply is
|
||||
/// still printed, the later lines still run, and the final `-o` screenshot is still written.
|
||||
#[test]
|
||||
fn snapshot_script_failure_exits_non_zero() {
|
||||
let script = tmp("snap-fail.jsonl");
|
||||
let out = tmp("snap-fail.png");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
json!({"method": "ui.set", "params": {"view": "grid"}}), // not a view: `photoGrid`, `detail`, …
|
||||
json!({"method": "ui.set", "params": {"view": "photoGrid"}}),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let o = Command::new(BIN)
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!o.status.success(), "a failed scripted request must fail the run\n{stderr}");
|
||||
assert!(stderr.contains("1 scripted request(s) failed"), "{stderr}");
|
||||
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or("").contains("grid"), "{replies:?}");
|
||||
assert_eq!(replies[1]["ok"], true, "{replies:?}");
|
||||
// continue-and-report: the final screenshot is still written
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
|
||||
/// without creating a GPU device (no driver is loaded), and says why.
|
||||
#[test]
|
||||
@@ -336,3 +369,29 @@ fn connect_failure_hint_names_the_attempted_port() {
|
||||
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
|
||||
assert!(!err.contains("--control 7980"), "{err}");
|
||||
}
|
||||
|
||||
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
|
||||
/// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared.
|
||||
#[test]
|
||||
fn warnings_are_logged_on_stderr() {
|
||||
let input = tmp("log-in.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("log-out.jpg");
|
||||
let run = |level: &str| {
|
||||
let o = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.env("LIGHTCRAFT_GPU_BACKEND", "bogus")
|
||||
.env("RUST_LOG", level)
|
||||
.env_remove("LIGHTCRAFT_LOG")
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(o.status.success(), "{stderr}");
|
||||
stderr
|
||||
};
|
||||
let warn = run("warn");
|
||||
assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}");
|
||||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
@@ -429,8 +429,17 @@ impl WebApp {
|
||||
cores.saturating_sub(1).clamp(1, 4)
|
||||
});
|
||||
let cache = app.session.media.rendered.clone();
|
||||
let workers =
|
||||
(n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone()));
|
||||
let workers = (n > 0).then(|| {
|
||||
Workers::start(
|
||||
n,
|
||||
backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
backend.clone(),
|
||||
index,
|
||||
&cache,
|
||||
app.session.cache_bytes(),
|
||||
cc.egui_ctx.clone(),
|
||||
)
|
||||
});
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
@@ -601,11 +610,16 @@ impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = ctx.input(|i| i.time);
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS));
|
||||
self.app.ui.toast = Some((text, now + NOTICE_SECS, None));
|
||||
}
|
||||
self.run_inbox();
|
||||
self.import_dropped(ctx);
|
||||
self.load_originals(ctx);
|
||||
if let Some(w) = &self.workers {
|
||||
// `library.preferences ▸ cacheMb` is read by the session; the worker index prunes on
|
||||
// its own thread, so it gets the budget every frame and applies it on the next store.
|
||||
w.set_budget(self.app.session.cache_bytes());
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
self.save();
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array, Uint32Array};
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use lightcraft_engine::media::{DISK_CACHE_BYTES, RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult, SourceLevel};
|
||||
use lightcraft_engine::pipeline::Rendered;
|
||||
use lightcraft_preview::{Hash128, PreviewCache};
|
||||
use lightcraft_raster::{Histogram, Rgba8};
|
||||
@@ -183,6 +183,9 @@ struct Inner {
|
||||
/// (photo, level) → worker that last decoded it.
|
||||
affinity: HashMap<(PhotoId, SourceLevel), usize>,
|
||||
index: ThumbIndex,
|
||||
/// The session's thumbnail cache budget in bytes. Kept here because the prune happens on a
|
||||
/// worker message, where the session is out of reach; the app refreshes it every frame.
|
||||
budget: u64,
|
||||
backend: Option<Backend>,
|
||||
ctx: egui::Context,
|
||||
/// Jobs finished by workers / inline (for diagnostics).
|
||||
@@ -214,7 +217,15 @@ impl Workers {
|
||||
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
|
||||
/// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
|
||||
/// now: watching it from the start, a clear before the first render request still counts.
|
||||
pub fn start(n: usize, store: &str, backend: Option<Backend>, index: ThumbIndex, cache: &Arc<PreviewCache>, ctx: egui::Context) -> Workers {
|
||||
pub fn start(
|
||||
n: usize,
|
||||
store: &str,
|
||||
backend: Option<Backend>,
|
||||
index: ThumbIndex,
|
||||
cache: &Arc<PreviewCache>,
|
||||
budget: u64,
|
||||
ctx: egui::Context,
|
||||
) -> Workers {
|
||||
let w = Workers(Rc::new(RefCell::new(Inner {
|
||||
preview_generation: CacheWatch::new(cache),
|
||||
workers: Vec::new(),
|
||||
@@ -222,6 +233,7 @@ impl Workers {
|
||||
next_id: 0,
|
||||
affinity: HashMap::new(),
|
||||
index,
|
||||
budget,
|
||||
backend,
|
||||
ctx,
|
||||
remote_done: 0,
|
||||
@@ -328,7 +340,10 @@ impl Workers {
|
||||
}
|
||||
if stored > 0 {
|
||||
g.index.insert(&hex, stored);
|
||||
let gone = g.index.prune(DISK_CACHE_BYTES);
|
||||
// The session's own budget (`library.preferences ▸ cacheMb`), not the native
|
||||
// default: the browser accepted the setting, so it has to be the one enforced
|
||||
// (#245).
|
||||
let gone = g.index.prune(g.budget);
|
||||
if !gone.is_empty()
|
||||
&& let Some(be) = g.backend.clone()
|
||||
{
|
||||
@@ -386,6 +401,12 @@ impl Workers {
|
||||
let ready = g.workers.iter().filter(|w| w.ready && !w.dead).count();
|
||||
(alive, ready, g.remote_done, g.inline_done)
|
||||
}
|
||||
|
||||
/// The budget the thumbnail index is pruned to. The app refreshes it from the session every
|
||||
/// frame, so `library.preferences ▸ cacheMb` applies to the next stored thumbnail.
|
||||
pub fn set_budget(&self, bytes: u64) {
|
||||
self.0.borrow_mut().budget = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOffload for Workers {
|
||||
|
||||
@@ -28,11 +28,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a
|
||||
//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings folder>/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux
|
||||
//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the
|
||||
//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`),
|
||||
//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS`
|
||||
//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs.
|
||||
//!
|
||||
//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu
|
||||
//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or
|
||||
//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors
|
||||
//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`,
|
||||
//! `warn,lightcraft_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every
|
||||
//! target that starts with it (`lightcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink. Panics that escape everything are added to the
|
||||
//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error).
|
||||
//!
|
||||
//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError, TryLockError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log folder.
|
||||
pub const LOG_FILE: &str = "lightcraft.log";
|
||||
/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise.
|
||||
pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info";
|
||||
|
||||
/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset).
|
||||
///
|
||||
/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or
|
||||
/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value
|
||||
/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`].
|
||||
pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String {
|
||||
let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
if let Some(level) = set(lightcraft_log) {
|
||||
return match level.as_str() {
|
||||
"debug" => "warn,lightcraft*=debug".to_owned(),
|
||||
"info" => "warn,lightcraft*=info".to_owned(),
|
||||
_ => "warn".to_owned(),
|
||||
};
|
||||
}
|
||||
set(rust_log).unwrap_or_else(|| DEFAULT_FILTER.to_owned())
|
||||
}
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
// No file is coming ([`Sink::no_file`]): standard error had the line.
|
||||
} else if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file` (a fresh one: the size cap starts over), first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
self.written = 0;
|
||||
self.capped = false;
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path. On an error
|
||||
/// standard error stays the only sink.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
// A thread that panicked while logging leaves the sink usable: take it back.
|
||||
let mut sink = self.sink.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
sink.attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
sink.no_file();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only.
|
||||
pub fn no_file(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
|
||||
}
|
||||
|
||||
/// Add a panic report to the log file only (the default panic hook has printed it on standard
|
||||
/// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on
|
||||
/// this very thread while it held the lock, and waiting would hang the app.
|
||||
pub fn record_panic(&self, thread: &str, report: &str) {
|
||||
let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report);
|
||||
let mut sink = match self.sink.try_lock() {
|
||||
Ok(sink) => sink,
|
||||
Err(TryLockError::Poisoned(p)) => p.into_inner(),
|
||||
Err(TryLockError::WouldBlock) => return,
|
||||
};
|
||||
sink.write(&line);
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from [`filter_spec`]); `None` when another logger was installed
|
||||
/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let var = |name| std::env::var(name).ok();
|
||||
let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref()));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
/// Chain a panic hook after the current one (the engine guard's: standard error plus
|
||||
/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file.
|
||||
pub fn record_panics(logger: &'static AppLogger) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
previous(info);
|
||||
let thread = std::thread::current();
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
let report = format!("panic{at}: {}", lightcraft_engine::guard::panic_message(info.payload()));
|
||||
logger.record_panic(thread.name().unwrap_or("?"), &report);
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) {
|
||||
use log::Log;
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
/// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and
|
||||
/// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them.
|
||||
fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool {
|
||||
let max = match lightcraft_log {
|
||||
"debug" => LevelFilter::Debug,
|
||||
"info" => LevelFilter::Info,
|
||||
_ => LevelFilter::Warn,
|
||||
};
|
||||
level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("lightcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft_engine::guard"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft_ui_egui::render"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
assert_eq!(filter_spec(None, None), DEFAULT_FILTER);
|
||||
// An empty variable is the same as an unset one.
|
||||
assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER);
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger.
|
||||
#[test]
|
||||
fn lightcraft_log_keeps_its_meaning() {
|
||||
let targets =
|
||||
["lightcraft", "lightcraft::control_server", "lightcraft_engine::segment", "lightcraft_gpu", "wgpu_core::device", "naga", "eframe"];
|
||||
let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace];
|
||||
for value in ["info", "debug", "warn", "error", "verbose", "INFO"] {
|
||||
let f = Filter::parse(&filter_spec(Some(value), None));
|
||||
for target in targets {
|
||||
for level in levels {
|
||||
assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(Filter::parse(&filter_spec(Some("debug"), None)).max(), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(&filter_spec(Some("warn"), None)).max(), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() {
|
||||
assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info");
|
||||
let f = Filter::parse(&filter_spec(None, Some("debug")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Debug);
|
||||
// The app's own variable is the more specific choice.
|
||||
let f = Filter::parse(&filter_spec(Some("info"), Some("trace")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("lightcraft_engine"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,lightcraft_pipeline=trace,lightcraft_pipeline::tiles=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("lightcraft_pipeline::stage"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("lightcraft_pipeline::tiles"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("lightcraft_raw::cr2"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"lightcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,lightcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "lightcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
let _ = filter_spec(Some(spec), Some(spec));
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"),
|
||||
"2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "before the settings directory");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Error, "lightcraft_engine::guard", "`develop.reset` failed unexpectedly: boom");
|
||||
record(&logger, log::Level::Debug, "lightcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("lightcraft_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("lightcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}"));
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "early");
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
assert!(logger.sink.lock().expect("sink").pending.is_empty());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
record(&logger, log::Level::Warn, "lightcraft", "after the poison");
|
||||
assert!(read(&path).contains("lightcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The panic hook's report lands in the file, and never deadlocks when the panic happened
|
||||
/// while this thread held the sink.
|
||||
#[test]
|
||||
fn a_panic_report_goes_to_the_file_and_never_deadlocks() {
|
||||
let dir = temp_dir("panic");
|
||||
let logger = AppLogger::new(Filter::parse("off"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.record_panic("render", "panicked at crates/x/src/lib.rs:3:5:\nboom");
|
||||
{
|
||||
let _held = logger.sink.lock().expect("lock");
|
||||
logger.record_panic("main", "while the sink is held");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
|
||||
assert!(!text.contains("while the sink is held"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+193
-55
@@ -24,6 +24,7 @@
|
||||
|
||||
mod alloc_release;
|
||||
mod control_server;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
@@ -63,10 +64,10 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
eprintln!("lightcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
if let Err(e) = self.0.session.close_library() {
|
||||
eprintln!("lightcraft: saving the library failed: {e}");
|
||||
log::error!("saving the library failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,10 +94,23 @@ fn gpu_marker_path() -> Option<std::path::PathBuf> {
|
||||
|
||||
/// Arm the GPU crash sentinel; if the previous launch left it behind (it died inside the GPU
|
||||
/// driver while creating the device), the notice to show — GPU rendering then starts off.
|
||||
fn gpu_crash_check() -> Option<String> {
|
||||
let marker = gpu_marker_path();
|
||||
let left = marker.as_deref().and_then(lightcraft_engine::gpu::backend::take_init_marker);
|
||||
lightcraft_engine::gpu::backend::set_init_marker(marker);
|
||||
///
|
||||
/// A `--memory` session writes nothing (issue #169): it neither arms the sentinel — which would
|
||||
/// create the settings folder — nor removes a marker it finds. It still honours one, so the GPU
|
||||
/// starts off there too, and the next ordinary launch reports and clears it.
|
||||
fn gpu_crash_check(in_memory: bool) -> Option<String> {
|
||||
gpu_crash_check_at(gpu_marker_path(), in_memory)
|
||||
}
|
||||
|
||||
fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Option<String> {
|
||||
use lightcraft_engine::gpu::backend;
|
||||
let left = if in_memory {
|
||||
marker.as_deref().and_then(backend::read_init_marker)
|
||||
} else {
|
||||
let left = marker.as_deref().and_then(backend::take_init_marker);
|
||||
backend::set_init_marker(marker);
|
||||
left
|
||||
};
|
||||
left.map(|what| gpu_crash_notice(&what))
|
||||
}
|
||||
|
||||
@@ -112,19 +126,28 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::camera_profiles::config_dir()
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("ui.json"))
|
||||
}
|
||||
|
||||
/// The saved UI state and app settings (`<config>/ui.json`), if any, and a warning for the user
|
||||
/// when the file exists but can't be used (issue #103): a damaged file is kept as
|
||||
/// `ui.json.corrupt-<unix time>` first, so the next save can't lose the library location in it;
|
||||
/// one that can't be read at all is not written this session (`keep_file`).
|
||||
fn load_prefs() -> (Option<UiState>, Option<String>, bool) {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return (None, None, false);
|
||||
}
|
||||
let Some(path) = config_dir().map(|d| d.join("ui.json")) else { return (None, None, false) };
|
||||
load_prefs_at(&path)
|
||||
///
|
||||
/// A `--memory` session reads the settings (so it looks like the user's app) but writes nothing
|
||||
/// (issue #164), so a damaged file is left as it is there, not set aside.
|
||||
fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let Some(path) = prefs_path() else { return (None, None, false) };
|
||||
load_prefs_at(&path, !in_memory)
|
||||
}
|
||||
|
||||
fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bool) {
|
||||
fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiState>, Option<String>, bool) {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (None, None, false),
|
||||
@@ -135,6 +158,10 @@ fn load_prefs_at(path: &std::path::Path) -> (Option<UiState>, Option<String>, bo
|
||||
};
|
||||
match serde_json::from_slice::<UiState>(&bytes) {
|
||||
Ok(ui) => (Some(ui.sanitized()), None, false),
|
||||
Err(e) if !set_aside_damaged => {
|
||||
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
|
||||
(None, Some(msg), true)
|
||||
}
|
||||
Err(e) => {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let keep = path.with_file_name(format!("ui.json.corrupt-{secs}"));
|
||||
@@ -177,8 +204,15 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
|
||||
/// Saves `ui.json` (app settings, incl. the library to open at launch): as soon as the library
|
||||
/// changes, every few seconds when anything else changed, and at exit.
|
||||
///
|
||||
/// Nothing is written while the session is one that promises to save nothing: `--memory`
|
||||
/// (`path` is `None`; issue #164), or the one running because the library couldn't be opened — the
|
||||
/// question window and Continue Without Saving (issue #100; `app.openLibrary` ends it, and the
|
||||
/// library it opened is saved then).
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
|
||||
path: Option<std::path::PathBuf>,
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
checked: f64,
|
||||
@@ -190,19 +224,26 @@ struct PrefsWriter {
|
||||
|
||||
impl PrefsWriter {
|
||||
fn save(&mut self, app: &LightcraftApp) -> Result<(), String> {
|
||||
if self.keep_file || std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
self.save_ui(&app.ui, app.library_problem.is_some())
|
||||
}
|
||||
|
||||
/// Write `ui` if it changed since the last write; `temporary` (the library couldn't be
|
||||
/// opened) saves nothing.
|
||||
fn save_ui(&mut self, ui: &UiState, temporary: bool) -> Result<(), String> {
|
||||
if self.keep_file || temporary {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(d) = config_dir() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(&app.ui).map_err(|e| e.to_string())?;
|
||||
let Some(path) = self.path.clone() else { return Ok(()) };
|
||||
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
|
||||
if bytes == self.written {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&d)
|
||||
.and_then(|()| write_atomic(&d.join("ui.json"), &bytes))
|
||||
path.parent()
|
||||
.map_or(Ok(()), std::fs::create_dir_all)
|
||||
.and_then(|()| write_atomic(&path, &bytes))
|
||||
.map_err(|e| format!("saving the app settings failed: {e}"))?;
|
||||
self.written = bytes;
|
||||
self.library = app.ui.settings.library_path.clone();
|
||||
self.library = ui.settings.library_path.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -216,8 +257,8 @@ impl PrefsWriter {
|
||||
match self.save(app) {
|
||||
Ok(()) => self.failing = false,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: {e}");
|
||||
if !self.failing {
|
||||
log::warn!("{e}");
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
}
|
||||
self.failing = true;
|
||||
@@ -272,7 +313,8 @@ fn services() -> Services {
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").args(["-R", path]).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
std::process::Command::new("explorer").arg(format!("/select,{path}")).status()
|
||||
let win_path = path.replace('/', "\\");
|
||||
std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
|
||||
} else {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
|
||||
std::process::Command::new("xdg-open").arg(dir).status()
|
||||
@@ -366,7 +408,7 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
@@ -374,8 +416,8 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
match s.open_library(&dir, seed_demo) {
|
||||
Ok(r) => {
|
||||
let (replayed, torn) = (r.replayed, r.torn_bytes);
|
||||
eprintln!(
|
||||
"lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||
log::info!(
|
||||
"library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
|
||||
dir.display(),
|
||||
s.catalog.len(),
|
||||
if torn > 0 { ", torn tail repaired" } else { "" },
|
||||
@@ -384,7 +426,7 @@ fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: boo
|
||||
(s, None)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft: can't open library {}: {e}", dir.display());
|
||||
log::error!("can't open library {}: {e}", dir.display());
|
||||
(unopened(), Some(LibraryProblem::new(dir.to_string_lossy(), e.to_string())))
|
||||
}
|
||||
}
|
||||
@@ -409,41 +451,40 @@ ENVIRONMENT:
|
||||
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
optional: LightCraft offers to download it when first needed)
|
||||
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
||||
LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
|
||||
from other crates); else RUST_LOG=<env_logger directives>. Records go to stderr and to
|
||||
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
|
||||
";
|
||||
|
||||
/// Warnings and errors (failed commands, AI mask analysis) on stderr; `LIGHTCRAFT_LOG=info`
|
||||
/// (or `debug`) shows more.
|
||||
struct StderrLog(log::LevelFilter);
|
||||
|
||||
impl log::Log for StderrLog {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= self.0 && (m.level() <= log::Level::Warn || m.target().starts_with("lightcraft"))
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
eprintln!("lightcraft: {} {}: {}", r.level(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
config_dir().map(|d| d.join("logs"))
|
||||
}
|
||||
|
||||
fn install_log() {
|
||||
let level = match std::env::var("LIGHTCRAFT_LOG").unwrap_or_default().as_str() {
|
||||
"debug" => log::LevelFilter::Debug,
|
||||
"info" => log::LevelFilter::Info,
|
||||
_ => log::LevelFilter::Warn,
|
||||
};
|
||||
static LOGGER: std::sync::OnceLock<StderrLog> = std::sync::OnceLock::new();
|
||||
if log::set_logger(LOGGER.get_or_init(|| StderrLog(level))).is_ok() {
|
||||
log::set_max_level(level);
|
||||
/// The control port `value` names; a value that isn't a port is logged and ignored (`what` is
|
||||
/// the option or variable it came from). Logged before the log file opens, so it is kept for it.
|
||||
fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
let value = value?;
|
||||
let port = value.trim().parse().ok();
|
||||
if port.is_none() {
|
||||
log::warn!("{what} {value:?} is not a port number; no control server");
|
||||
}
|
||||
port
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
install_log();
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
alloc_release::install();
|
||||
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty()));
|
||||
let mut files = Vec::new();
|
||||
let mut seed_demo = true;
|
||||
let mut in_memory = false;
|
||||
@@ -451,7 +492,7 @@ fn main() -> eframe::Result {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control" => control_port = control_port_from("--control", Some(args.next().unwrap_or_default())),
|
||||
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
|
||||
"--no-demo" => seed_demo = false,
|
||||
"--memory" | "--demo" => in_memory = true,
|
||||
@@ -470,7 +511,20 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs();
|
||||
// The log file: opened after the arguments, so `--version`, `--help` and a usage error leave
|
||||
// no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
// a --memory session writes nothing (issue #164), a log file included
|
||||
match log_dir().filter(|_| !in_memory) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
// The file sink has given up by now, so this goes to standard error only.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
let (prefs, prefs_warning, keep_prefs_file) = load_prefs(in_memory);
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
@@ -482,7 +536,7 @@ fn main() -> eframe::Result {
|
||||
let library_dir = library_dir.or_else(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();
|
||||
let gpu_crash = gpu_crash_check(in_memory);
|
||||
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
||||
lightcraft_engine::gpu::set_enabled(gpu_on);
|
||||
let options = eframe::NativeOptions {
|
||||
@@ -520,6 +574,7 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(prefs_warning);
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
written: serde_json::to_vec_pretty(&app.ui).unwrap_or_default(),
|
||||
library: app.ui.settings.library_path.clone(),
|
||||
keep_file: keep_prefs_file,
|
||||
@@ -575,7 +630,7 @@ mod tests {
|
||||
let d = dir("damaged");
|
||||
let path = d.join("ui.json");
|
||||
std::fs::write(&path, br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path);
|
||||
let (ui, warning, keep) = load_prefs_at(&path, true);
|
||||
assert!(ui.is_none() && !keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("ui.json.corrupt-"), "{warning}");
|
||||
@@ -583,7 +638,80 @@ mod tests {
|
||||
let kept: Vec<_> = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(kept.len() == 1 && kept[0].starts_with("ui.json.corrupt-"), "{kept:?}");
|
||||
// a missing file is just "no settings yet"
|
||||
assert_eq!(load_prefs_at(&path).1, None);
|
||||
assert_eq!(load_prefs_at(&path, true).1, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #164: a `--memory` session reads the settings but writes nothing — a damaged file is
|
||||
/// reported and left where it is, not set aside.
|
||||
#[test]
|
||||
fn memory_session_leaves_a_damaged_ui_json_alone() {
|
||||
let d = dir("damaged-memory");
|
||||
let path = d.join("ui.json");
|
||||
let bytes = br#"{"settings": {"library_path": "/Volumes/Photos/Lib"#;
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let (ui, warning, keep) = load_prefs_at(&path, false);
|
||||
assert!(ui.is_none() && keep);
|
||||
let warning = warning.unwrap();
|
||||
assert!(warning.contains("damaged") && warning.contains("nothing is saved"), "{warning}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
|
||||
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
|
||||
#[test]
|
||||
fn prefs_writer_writes_nothing_in_a_memory_or_temporary_session() {
|
||||
let d = dir("writer");
|
||||
let path = d.join("sub").join("ui.json");
|
||||
let mut ui = UiState::default();
|
||||
// an ordinary session: a change is written, the folder created on the way
|
||||
let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(String::from_utf8_lossy(&saved).contains("thumbSize"), "{}", String::from_utf8_lossy(&saved));
|
||||
// the temporary session (library problem): a further change is not written…
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, true).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), saved);
|
||||
// …until a library opens
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), saved);
|
||||
// --memory: no path, so no file and no folder, whatever changes
|
||||
let d2 = dir("writer-memory");
|
||||
let _ = std::fs::remove_dir_all(&d2);
|
||||
let mut w = PrefsWriter { path: None, ..Default::default() };
|
||||
ui.thumb_size += 50.0;
|
||||
w.save_ui(&ui, false).unwrap();
|
||||
assert!(w.written.is_empty() && !d2.exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #169: a `--memory` session neither arms the GPU crash sentinel (which would create
|
||||
/// the settings folder) nor removes a marker a crashed launch left; it still reports it, and
|
||||
/// the next ordinary launch finds it, reports it and clears it.
|
||||
#[test]
|
||||
fn memory_session_leaves_the_gpu_marker_and_folder_alone() {
|
||||
let d = dir("gpu-marker-memory");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let m = d.join("gpu-init.marker");
|
||||
// nothing left behind: no notice, and the folder is not created
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), true), None);
|
||||
assert!(!d.exists(), "a --memory session must not create the settings folder");
|
||||
// a marker left by a crashed launch: reported, kept
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(&m, "GPU device creation started (backends METAL)").unwrap();
|
||||
let notice = gpu_crash_check_at(Some(m.clone()), true).unwrap();
|
||||
assert!(notice.contains("GPU rendering is now off"), "{notice}");
|
||||
assert!(m.exists(), "the marker stays for the next ordinary launch");
|
||||
// the next ordinary launch reports it once and removes it
|
||||
assert!(gpu_crash_check_at(Some(m.clone()), false).is_some());
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
assert!(!m.exists());
|
||||
assert_eq!(gpu_crash_check_at(Some(m.clone()), false), None);
|
||||
lightcraft_engine::gpu::backend::set_init_marker(None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -616,6 +744,16 @@ mod tests {
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_is_not_a_number_is_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
|
||||
assert_eq!(control_port_from("--control", Some(" 18001 ".into())), Some(18001));
|
||||
assert_eq!(control_port_from("--control", Some("nope".into())), None);
|
||||
assert_eq!(control_port_from("--control", Some(String::new())), None);
|
||||
assert_eq!(control_port_from("--control", Some("70000".into())), None);
|
||||
assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_json_is_written_atomically() {
|
||||
let d = dir("atomic");
|
||||
@@ -630,7 +768,7 @@ mod tests {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"{\"new\": true}");
|
||||
let ui = UiState::default();
|
||||
write_atomic(&path, &serde_json::to_vec_pretty(&ui).unwrap()).unwrap();
|
||||
let (loaded, warning, _) = load_prefs_at(&path);
|
||||
let (loaded, warning, _) = load_prefs_at(&path, true);
|
||||
assert!(loaded.is_some() && warning.is_none());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts,
|
||||
//! enabled and checked state, and live labels ("Undo Exposure").
|
||||
//!
|
||||
//! Key handling: a native key equivalent consumes the key press before egui sees it, so
|
||||
//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by
|
||||
//! the egui shortcut handler (nothing fires twice);
|
||||
//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a
|
||||
//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose
|
||||
//! winit view always takes it, so a menu item never fires from it. Hence
|
||||
//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in
|
||||
//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires
|
||||
//! twice); the others stay on their menu items for display and egui runs them;
|
||||
//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the
|
||||
//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come
|
||||
//! back when the field loses focus.
|
||||
@@ -146,6 +149,18 @@ 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::<u8>().is_ok()))
|
||||
}
|
||||
|
||||
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
|
||||
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
|
||||
installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn label_text(s: &str) -> String {
|
||||
lightcraft_ui_egui::i18n::tr(s).replace('&', "&&")
|
||||
@@ -329,13 +344,8 @@ impl NativeMenu {
|
||||
|
||||
/// Tell the egui shortcut handler which shortcuts the menu bar currently owns.
|
||||
fn publish_shortcuts(&self, app: &mut LightcraftApp) {
|
||||
app.native_shortcuts = self
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.accel.is_some())
|
||||
.filter_map(|i| i.shortcut.clone())
|
||||
.filter(|sc| !(self.text_focus && yields_to_text(sc)))
|
||||
.collect::<HashSet<_>>();
|
||||
let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref());
|
||||
app.native_shortcuts = owned_by_menu(installed, self.text_focus);
|
||||
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
|
||||
}
|
||||
|
||||
@@ -467,4 +477,39 @@ mod tests {
|
||||
assert!(accelerator(sc).is_some(), "{sc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must
|
||||
/// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own.
|
||||
#[test]
|
||||
fn single_key_shortcuts_stay_with_egui() {
|
||||
assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11"));
|
||||
assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F"));
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
fn all(n: &[MenuNode], out: &mut Vec<String>) {
|
||||
for x in n {
|
||||
match x {
|
||||
MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()),
|
||||
MenuNode::Submenu { children, .. } => all(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut installed = Vec::new();
|
||||
for (_, v) in menu_bar(&app) {
|
||||
all(&v, &mut installed);
|
||||
}
|
||||
for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar");
|
||||
}
|
||||
let owned = owned_by_menu(installed.iter().map(String::as_str), false);
|
||||
for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] {
|
||||
assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu");
|
||||
assert!(!owned.contains(sc), "{sc} must be left to egui");
|
||||
}
|
||||
for sc in ["Cmd+Shift+H", "F2"] {
|
||||
assert!(owned.contains(sc), "{sc} is run by the menu bar");
|
||||
}
|
||||
// while typing, the menu bar gives up F2 too (the text field has it)
|
||||
assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! The library's folder tree: where on disk the imported photos live.
|
||||
//!
|
||||
//! A *folder* here is a folder holding at least one photo of the library (imported, not deleted,
|
||||
//! not merely browsed in Local; demo scenes have no folder). The tree lists those folders and the
|
||||
//! ones above them, never a folder that holds nothing of the library, with how many photos each
|
||||
//! holds — its subfolders included, like a year in By Date counts its months.
|
||||
//!
|
||||
//! Every folder is a row of its own, the ones above the imported photos included
|
||||
//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two
|
||||
//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see
|
||||
//! [`crate::query::folder_key`]).
|
||||
//!
|
||||
//! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share
|
||||
//! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`,
|
||||
//! `/mnt/disk`, `/run/user/1000/gvfs/smb-share:…`, `/net/host`) or, for everything else, the
|
||||
//! startup disk (`/`). Only the spelling of the stored paths tells them apart: no disk is
|
||||
//! touched, so a share that is asleep or gone lists and counts like one that is there, and the
|
||||
//! sidebar never waits on the network.
|
||||
//!
|
||||
//! Limits of reading only the stored spelling of a path: file names are compared as written
|
||||
//! (Windows folds case, other systems do not), so on a case-insensitive disk `/Volumes/tokyo` and
|
||||
//! `/Volumes/TOKYO` are two volumes here; and one place reached by two spellings
|
||||
//! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is
|
||||
//! two rows. Each row is still consistent: choosing it shows exactly the photos it counts.
|
||||
//!
|
||||
//! This is the library's own view of its photos; browsing any folder on disk without importing
|
||||
//! is Local's job (see [`crate::local`]).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::query::{folder_key, folder_within, split_path};
|
||||
use crate::{Catalog, Source};
|
||||
|
||||
/// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its
|
||||
/// ancestor this many levels down, which bounds the tree's depth (and so every walk of it)
|
||||
/// whatever a catalog holds.
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One row of the library's folder tree.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct FolderNode {
|
||||
/// What the row says: the folder's name; for a volume, its name (`nas`, `D:`,
|
||||
/// `//server/share`, `/` for the startup disk).
|
||||
pub name: String,
|
||||
/// The folder the row stands for, written out from the names of the photos' paths (forward
|
||||
/// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its
|
||||
/// photos.
|
||||
pub path: String,
|
||||
/// Photos in this folder or any folder inside it.
|
||||
pub count: usize,
|
||||
/// Photos directly in this folder.
|
||||
pub own: usize,
|
||||
/// A disk rather than a folder in one; its `path` is where it is mounted.
|
||||
pub volume: bool,
|
||||
/// Whether choosing the row (`Filter::library_folder`) shows exactly its `count` photos. Not
|
||||
/// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their
|
||||
/// path covers those disks' photos too, so choosing them is not offered.
|
||||
pub selectable: bool,
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
/// A folder while the tree is being built (children by identity key).
|
||||
struct Level {
|
||||
name: String,
|
||||
path: String,
|
||||
own: usize,
|
||||
volume: bool,
|
||||
children: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where a stored folder path lies: its volume and the folder names below it.
|
||||
struct Placed {
|
||||
mount: String,
|
||||
volume: String,
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
/// A gvfs mount's name made readable: `smb-share:server=tokyo,share=photos` → `photos on tokyo`;
|
||||
/// any other (`sftp:host=nas`) as it is.
|
||||
fn mount_name(n: &str) -> String {
|
||||
let field = |key: &str| {
|
||||
let rest = n.strip_prefix("smb-share:")?;
|
||||
rest.split(',').find_map(|kv| kv.strip_prefix(key)?.strip_prefix('=')).filter(|v| !v.is_empty())
|
||||
};
|
||||
match (field("share"), field("server")) {
|
||||
(Some(share), Some(server)) => format!("{share} on {server}"),
|
||||
_ => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume `dir` lies on and the folder names below it, read the way [`folder_key`] reads a
|
||||
/// path (so a row and the filter that chooses it never disagree). `None` for a path that is not
|
||||
/// absolute or names no share.
|
||||
fn place(dir: &str) -> Option<Placed> {
|
||||
let sp = split_path(dir, false);
|
||||
let names: Vec<&str> = sp.parts.iter().map(String::as_str).collect();
|
||||
let named = |mount: String, volume: &str, used: usize| (mount, volume.to_string(), used);
|
||||
let (mount, volume, used) = if sp.prefix.starts_with("//") {
|
||||
// `//server/share`: a server alone (no share) is no place to keep photos
|
||||
if sp.prefix.ends_with('/') {
|
||||
return None;
|
||||
}
|
||||
named(sp.prefix.clone(), &sp.prefix, 0)
|
||||
} else if !sp.prefix.is_empty() {
|
||||
named(sp.prefix.clone(), &sp.prefix, 0)
|
||||
} else if sp.absolute {
|
||||
match names.as_slice() {
|
||||
["Volumes", n, ..] => named(format!("/Volumes/{n}"), n, 2),
|
||||
["run", "media", u, n, ..] => named(format!("/run/media/{u}/{n}"), n, 4),
|
||||
["media", u, n, ..] => named(format!("/media/{u}/{n}"), n, 3),
|
||||
["mnt", n, ..] => named(format!("/mnt/{n}"), n, 2),
|
||||
// Linux desktops mount network places per user, naming server and share
|
||||
["run", "user", uid, "gvfs", n, ..] => named(format!("/run/user/{uid}/gvfs/{n}"), &mount_name(n), 5),
|
||||
// NFS automounts: Linux `/net/<host>`, macOS `/Network/Servers/<host>`
|
||||
["net", host, ..] => named(format!("/net/{host}"), host, 2),
|
||||
["Network", "Servers", host, ..] => named(format!("/Network/Servers/{host}"), host, 3),
|
||||
_ => named("/".to_string(), "/", 0),
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(Placed { mount, volume, names: names.iter().skip(used).take(MAX_DEPTH).map(|n| n.to_string()).collect() })
|
||||
}
|
||||
|
||||
/// The disk or share (`/Volumes/nas`, `D:`, `/` for the startup disk) a file-backed photo is on,
|
||||
/// from its stored path alone.
|
||||
pub fn volume_of(p: &crate::Photo) -> Option<String> {
|
||||
let Source::File { path } = &p.source else { return None };
|
||||
let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?;
|
||||
place(dir).map(|pl| folder_key(&pl.mount))
|
||||
}
|
||||
|
||||
/// Whether `path` is a whole disk or share (`/`, `C:\`, `\\srv\share`, `/Volumes/nas`), not a
|
||||
/// folder in one.
|
||||
pub fn is_disk_root(path: &str) -> bool {
|
||||
place(path).is_some_and(|p| p.names.is_empty())
|
||||
}
|
||||
|
||||
/// Whether `path` is the startup disk's root (`/`), whose path also covers every other disk.
|
||||
pub fn is_startup_disk(path: &str) -> bool {
|
||||
place(path).is_some_and(|p| p.names.is_empty() && p.mount == "/")
|
||||
}
|
||||
|
||||
/// A folder named by its last two names (`photos/travel`), so two folders called `travel` are
|
||||
/// told apart; the path itself when it has no names (`/`).
|
||||
pub fn folder_label(path: &str) -> String {
|
||||
let names: Vec<&str> = path.split(['/', '\\']).filter(|n| !n.is_empty()).collect();
|
||||
if names.is_empty() { path.to_string() } else { names.iter().skip(names.len().saturating_sub(2)).copied().collect::<Vec<_>>().join("/") }
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The library's volumes with their folders and photo counts: volumes by name
|
||||
/// (case-insensitive), each folder with its subfolders the same way.
|
||||
pub fn folder_tree(&self) -> Vec<FolderNode> {
|
||||
// photos per containing folder, as spelled (sorted, so the spelling that names a folder
|
||||
// never depends on hash order)
|
||||
let mut dirs: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
if let Source::File { path } = &p.source
|
||||
&& let Some(i) = path.rfind(['/', '\\'])
|
||||
// a photo straight in the root has an empty folder part: the root itself
|
||||
&& let Some(dir) = path.get(..i.max(1))
|
||||
{
|
||||
*dirs.entry(dir).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut roots: Vec<String> = Vec::new();
|
||||
for (dir, n) in dirs {
|
||||
let Some(placed) = place(dir) else { continue };
|
||||
let vkey = folder_key(&placed.mount);
|
||||
if !levels.contains_key(&vkey) {
|
||||
levels.insert(
|
||||
vkey.clone(),
|
||||
Level { name: placed.volume.clone(), path: placed.mount.clone(), own: 0, volume: true, children: Vec::new() },
|
||||
);
|
||||
roots.push(vkey.clone());
|
||||
}
|
||||
let mut prefix = if placed.mount == "/" { String::new() } else { placed.mount.clone() };
|
||||
let mut parent = vkey;
|
||||
for name in &placed.names {
|
||||
prefix.push('/');
|
||||
prefix.push_str(name);
|
||||
let key = folder_key(&prefix);
|
||||
if !levels.contains_key(&key) {
|
||||
levels.insert(key.clone(), Level { name: name.clone(), path: prefix.clone(), own: 0, volume: false, children: Vec::new() });
|
||||
if let Some(l) = levels.get_mut(&parent) {
|
||||
l.children.push(key.clone());
|
||||
}
|
||||
}
|
||||
parent = key;
|
||||
}
|
||||
if let Some(l) = levels.get_mut(&parent) {
|
||||
l.own += n;
|
||||
}
|
||||
}
|
||||
let mut tree: Vec<FolderNode> = roots.iter().filter_map(|k| build(k, &levels)).collect();
|
||||
sort(&mut tree);
|
||||
// a folder that holds another disk's mount covers that disk's photos too
|
||||
let mounts: Vec<String> = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect();
|
||||
mark_selectable(&mut tree, &mounts);
|
||||
tree
|
||||
}
|
||||
}
|
||||
|
||||
/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep).
|
||||
fn build(key: &str, levels: &HashMap<String, Level>) -> Option<FolderNode> {
|
||||
let l = levels.get(key)?;
|
||||
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
|
||||
sort(&mut children);
|
||||
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
|
||||
Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children })
|
||||
}
|
||||
|
||||
fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) {
|
||||
for n in nodes {
|
||||
n.selectable = !(n.volume && n.path == "/") && (n.volume || !mounts.iter().any(|m| folder_within(m, &n.path)));
|
||||
mark_selectable(&mut n.children, mounts);
|
||||
}
|
||||
}
|
||||
|
||||
fn sort(v: &mut [FolderNode]) {
|
||||
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)));
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod dates;
|
||||
pub mod folders;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
@@ -29,6 +30,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use dates::{DateRun, GroupBy};
|
||||
pub use folders::FolderNode;
|
||||
pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming};
|
||||
pub use keywords::KeywordNode;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
@@ -612,6 +614,22 @@ impl Catalog {
|
||||
Op::Batch { ops }
|
||||
}
|
||||
|
||||
/// [`Self::delete_permanently_ops`] for several photos at once. One op list built against the
|
||||
/// catalog as it is now: albums lose all of them in one write each, and stacks are shortened
|
||||
/// (or dissolved) once, however many of their photos go.
|
||||
pub fn delete_photos_permanently_ops(&self, ids: &[PhotoId]) -> Op {
|
||||
let gone: std::collections::HashSet<PhotoId> = ids.iter().copied().collect();
|
||||
let mut ops: Vec<Op> = self
|
||||
.albums
|
||||
.values()
|
||||
.filter(|a| a.photos.iter().any(|p| gone.contains(p)))
|
||||
.map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| !gone.contains(p)).collect() })
|
||||
.collect();
|
||||
ops.extend(self.remove_from_stacks_ops(ids));
|
||||
ops.extend(gone.iter().map(|id| Op::RemovePhoto { id: *id }));
|
||||
Op::Batch { ops }
|
||||
}
|
||||
|
||||
// ---- persistence
|
||||
|
||||
/// Full snapshot as JSON.
|
||||
@@ -655,6 +673,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
|
||||
+93
-19
@@ -37,6 +37,10 @@ pub struct Filter {
|
||||
pub deleted: bool,
|
||||
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
|
||||
pub date: Option<String>,
|
||||
/// A folder the library's photos were imported from: those photos and the ones imported from
|
||||
/// the folders inside it (see [`crate::folders`]). Photos only browsed in Local are never part
|
||||
/// of it, unlike [`Filter::folder`].
|
||||
pub library_folder: Option<String>,
|
||||
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
|
||||
pub keyword: Option<String>,
|
||||
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
|
||||
@@ -186,6 +190,7 @@ impl Filter {
|
||||
("camera", &self.camera),
|
||||
("lens", &self.lens),
|
||||
("date", &self.date),
|
||||
("folder", &self.library_folder),
|
||||
("imported", &self.imported),
|
||||
] {
|
||||
if let Some(x) = val {
|
||||
@@ -213,6 +218,19 @@ impl Filter {
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
self.matches_in(p, cat, self.library_root().as_deref())
|
||||
}
|
||||
|
||||
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
|
||||
fn library_root(&self) -> Option<String> {
|
||||
// as given: a folder may be named with spaces at its edge, and trimming would make it
|
||||
// another folder
|
||||
self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key)
|
||||
}
|
||||
|
||||
/// [`Filter::matches`] with the library folder already turned into its identity, so a query
|
||||
/// over a big library does that once, not once per photo.
|
||||
fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
@@ -285,6 +303,11 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = root
|
||||
&& !photo_in_root(p, root)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = &self.imported
|
||||
&& !p.imported.starts_with(d.as_str())
|
||||
{
|
||||
@@ -317,7 +340,8 @@ impl Filter {
|
||||
impl Catalog {
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
|
||||
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect();
|
||||
let root = filter.library_root();
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = match sort.key {
|
||||
SortKey::CaptureDate => a.date().cmp(b.date()),
|
||||
@@ -384,7 +408,8 @@ impl Catalog {
|
||||
pub fn people_in(&self, filter: &Filter) -> Vec<Person> {
|
||||
let filter = Filter { person: None, ..filter.clone() };
|
||||
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
|
||||
for p in self.photos().filter(|p| filter.matches(p, self)) {
|
||||
let root = filter.library_root();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
|
||||
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
@@ -438,12 +463,18 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
|
||||
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
/// A path read the way [`folder_key`] reads it: the part `..` can't climb above (`//server/share`,
|
||||
/// `c:`; empty for `/` and relative paths), the names below it, and whether it is absolute.
|
||||
pub(crate) struct SplitPath {
|
||||
pub prefix: String,
|
||||
pub parts: Vec<String>,
|
||||
pub absolute: bool,
|
||||
}
|
||||
|
||||
/// Both separators, repeated separators (also between a server and its share), `.` and a
|
||||
/// trailing separator dropped, `..` resolved lexically, a Windows verbatim prefix removed;
|
||||
/// `lower_drive` lower-cases a drive letter (the identity does, a display name wants it as is).
|
||||
pub(crate) fn split_path(path: &str, lower_drive: bool) -> SplitPath {
|
||||
let mut s = path.replace('\\', "/");
|
||||
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
|
||||
s = match rest.strip_prefix("UNC/") {
|
||||
@@ -451,28 +482,39 @@ pub fn folder_key(path: &str) -> String {
|
||||
None => rest.to_string(),
|
||||
};
|
||||
}
|
||||
// the part `..` can't climb above: `//server/share`, `c:` or `/`
|
||||
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
|
||||
let mut it = unc.splitn(3, '/');
|
||||
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), rest.to_string())
|
||||
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
|
||||
(s[..2].to_ascii_lowercase(), s[2..].to_string())
|
||||
let mut segs = unc.split('/').filter(|x| !x.is_empty());
|
||||
let (server, share) = (segs.next().unwrap_or(""), segs.next().unwrap_or(""));
|
||||
(format!("//{server}/{share}"), segs.collect::<Vec<_>>().join("/"))
|
||||
} else if let (true, Some(drive), Some(rest)) = (matches!(s.as_bytes(), [a, b':', ..] if a.is_ascii_alphabetic()), s.get(..2), s.get(2..)) {
|
||||
(if lower_drive { drive.to_ascii_lowercase() } else { drive.to_string() }, rest.to_string())
|
||||
} else {
|
||||
(String::new(), s.clone())
|
||||
};
|
||||
let absolute = rest.starts_with('/') || !prefix.is_empty();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for c in rest.split('/') {
|
||||
match c {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
".." if parts.last().is_some_and(|p| p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
".." if absolute => {}
|
||||
c => parts.push(c),
|
||||
c => parts.push(c.to_string()),
|
||||
}
|
||||
}
|
||||
SplitPath { prefix, parts, absolute }
|
||||
}
|
||||
|
||||
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
|
||||
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
|
||||
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
|
||||
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
|
||||
/// File names are otherwise compared as written, so on a case-insensitive disk elsewhere
|
||||
/// (macOS, a Samba share) two spellings that differ in case are two folders here.
|
||||
/// No file-system access: the paths compared should already be absolute.
|
||||
pub fn folder_key(path: &str) -> String {
|
||||
let SplitPath { prefix, parts, absolute } = split_path(path, true);
|
||||
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
|
||||
if cfg!(windows) { key.to_lowercase() } else { key }
|
||||
}
|
||||
@@ -480,8 +522,40 @@ pub fn folder_key(path: &str) -> String {
|
||||
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
|
||||
/// (see [`folder_key`]).
|
||||
pub fn folder_within(path: &str, root: &str) -> bool {
|
||||
let (p, r) = (folder_key(path), folder_key(root));
|
||||
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
|
||||
key_within(&folder_key(path), &folder_key(root))
|
||||
}
|
||||
|
||||
/// Whether a library photo (not one only browsed in Local) lies in the folder whose
|
||||
/// [`folder_key`] is `root`. A plain POSIX path (no `\`, `.`, `..`, repeated or trailing
|
||||
/// separators) is its own identity, so it is read without building a key for it: the check runs
|
||||
/// for every photo of the library on each refresh, and this keeps it cheap.
|
||||
pub(crate) fn photo_in_root(p: &Photo, root: &str) -> bool {
|
||||
if p.local {
|
||||
return false;
|
||||
}
|
||||
let crate::Source::File { path } = &p.source else { return false };
|
||||
let plain = path.starts_with('/')
|
||||
&& !path.starts_with("//")
|
||||
&& !path.ends_with('/')
|
||||
&& !path.contains(['\\'])
|
||||
&& !path.contains("//")
|
||||
&& !path.contains("/.");
|
||||
if !cfg!(windows) && plain && root.starts_with('/') && !root.starts_with("//") {
|
||||
let r = root.trim_end_matches('/');
|
||||
return path.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'));
|
||||
}
|
||||
crate::local::folder_of(p).is_some_and(|f| key_within(&f, root))
|
||||
}
|
||||
|
||||
/// [`folder_within`] for two paths already turned into their [`folder_key`].
|
||||
pub(crate) fn key_within(p: &str, r: &str) -> bool {
|
||||
// `.` or `a/..` name no folder: not "everything"
|
||||
if r.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// a disk's root key ends in `/` (`c:/`, `//srv/share/`), a folder's does not: same folder
|
||||
let (p, r) = (p.trim_end_matches('/'), r.trim_end_matches('/'));
|
||||
p == r || p.strip_prefix(r).is_some_and(|rest| rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The library's folder tree: where the imported photos live on disk (see [`crate::folders`]).
|
||||
//!
|
||||
//! Scenarios, in the words of someone browsing their library:
|
||||
//!
|
||||
//! * Given photos imported from several folders, the tree lists those folders and no others,
|
||||
//! each with how many photos it holds, under the disk (volume) they are on.
|
||||
//! * A folder's count includes the folders inside it.
|
||||
//! * Every folder is a row of its own, the ones above the imported photos included.
|
||||
//! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere.
|
||||
//! * However a folder is spelled, it is one folder.
|
||||
//! * Choosing a folder shows what was imported from it and from the folders inside it — exactly
|
||||
//! as many photos as the row says.
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn add(c: &mut Catalog, path: &str) -> PhotoId {
|
||||
add_with(c, path, |_| {})
|
||||
}
|
||||
|
||||
fn add_with(c: &mut Catalog, path: &str, tweak: impl FnOnce(&mut Photo)) -> PhotoId {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
tweak(&mut p);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn library(paths: &[&str]) -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
paths.iter().for_each(|p| {
|
||||
add(&mut c, p);
|
||||
});
|
||||
c
|
||||
}
|
||||
|
||||
/// `(name, count, own)` of each node, depth first.
|
||||
fn outline(nodes: &[FolderNode]) -> Vec<(String, usize, usize)> {
|
||||
fn walk(n: &FolderNode, out: &mut Vec<(String, usize, usize)>) {
|
||||
out.push((n.name.clone(), n.count, n.own));
|
||||
n.children.iter().for_each(|c| walk(c, out));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
nodes.iter().for_each(|n| walk(n, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
fn row(name: &str, count: usize, own: usize) -> (String, usize, usize) {
|
||||
(name.to_string(), count, own)
|
||||
}
|
||||
|
||||
fn rows(paths: &[&str]) -> Vec<(String, usize, usize)> {
|
||||
outline(&library(paths).folder_tree())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_has_no_folders() {
|
||||
assert!(Catalog::new().folder_tree().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_folders_with_imported_photos_are_listed_with_their_counts() {
|
||||
let c = library(&["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(outline(&tree), vec![row("/", 3, 0), row("pics", 3, 0), row("home", 1, 1), row("trip", 2, 2)]);
|
||||
assert!(tree[0].volume && !tree[0].children[0].volume);
|
||||
assert_eq!(tree[0].children[0].children[1].path, "/pics/trip", "a row knows its folder, for choosing it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folders_count_includes_the_folders_inside_it() {
|
||||
let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]);
|
||||
assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() {
|
||||
let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(
|
||||
outline(&tree),
|
||||
vec![
|
||||
row("/", 2, 0),
|
||||
row("Users", 2, 0),
|
||||
row("me", 2, 0),
|
||||
row("Pictures", 2, 0),
|
||||
row("2024", 1, 1),
|
||||
row("2025", 1, 1),
|
||||
row("nas", 1, 0),
|
||||
row("photos", 1, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_with_photos_of_its_own_is_not_folded_into_its_child() {
|
||||
assert_eq!(rows(&["/pics/a.jpg", "/pics/trip/b.jpg"]), vec![row("/", 2, 0), row("pics", 2, 1), row("trip", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() {
|
||||
let mut c = Catalog::new();
|
||||
add(&mut c, "/pics/kept/a.jpg");
|
||||
add_with(&mut c, "/pics/browsed/b.jpg", |p| p.local = true);
|
||||
add_with(&mut c, "/pics/gone/c.jpg", |p| p.deleted = true);
|
||||
let id = c.alloc_photo_id();
|
||||
let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap();
|
||||
assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn however_a_folder_is_spelled_it_is_one_folder() {
|
||||
let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]);
|
||||
assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_disk_is_a_volume_of_its_own() {
|
||||
// macOS, Linux and Windows habits for where disks appear
|
||||
let r = rows(&["/Volumes/nas/p/1.jpg", "/media/me/usb/2.jpg", "/run/media/me/card/3.jpg", "/mnt/disk/4.jpg"]);
|
||||
assert_eq!(r, vec![row("card", 1, 1), row("disk", 1, 1), row("nas", 1, 0), row("p", 1, 1), row("usb", 1, 1)]);
|
||||
let r = rows(&["D:\\Photos\\2024\\a.jpg", "D:\\Photos\\2025\\b.jpg", "C:\\x\\c.jpg"]);
|
||||
assert_eq!(r, vec![row("C:", 1, 0), row("x", 1, 1), row("D:", 2, 0), row("Photos", 2, 0), row("2024", 1, 1), row("2025", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_share_is_a_volume_and_two_spellings_are_one() {
|
||||
let r = rows(&["\\\\srv\\s1\\a\\1.jpg", "\\\\srv\\s2\\b\\2.jpg"]);
|
||||
assert_eq!(r, vec![row("//srv/s1", 1, 0), row("a", 1, 1), row("//srv/s2", 1, 0), row("b", 1, 1)]);
|
||||
// the verbatim form Windows APIs give is the same share
|
||||
let r = rows(&["\\\\?\\UNC\\srv\\s1\\a\\1.jpg", "\\\\srv\\s1\\a\\2.jpg"]);
|
||||
assert_eq!(r, vec![row("//srv/s1", 2, 0), row("a", 2, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_samba_share_mounted_on_a_mac_is_a_volume_named_after_the_share() {
|
||||
// Finder mounts every share under /Volumes, and a second mount of the same share gets a suffix
|
||||
let r = rows(&["/Volumes/tokyo/Photos/2024/a.jpg", "/Volumes/tokyo/Photos/2025/b.jpg", "/Volumes/tokyo-1/old/c.jpg", "/Volumes/東京 NAS/d.jpg"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
row("tokyo", 2, 0),
|
||||
row("Photos", 2, 0),
|
||||
row("2024", 1, 1),
|
||||
row("2025", 1, 1),
|
||||
row("tokyo-1", 1, 0),
|
||||
row("old", 1, 1),
|
||||
row("東京 NAS", 1, 1)
|
||||
]
|
||||
);
|
||||
let tree = library(&["/Volumes/tokyo/Photos/a.jpg"]).folder_tree();
|
||||
assert_eq!((tree[0].path.as_str(), tree[0].volume), ("/Volumes/tokyo", true), "choosing the share shows everything on it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shares_the_other_systems_mount_are_volumes_too() {
|
||||
// Linux desktops: gvfs mounts per user, with the server and share in the mount's name
|
||||
let gvfs = "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos";
|
||||
let r = rows(&[&format!("{gvfs}/2024/a.jpg"), "/run/user/1000/gvfs/sftp:host=nas/b.jpg"]);
|
||||
assert_eq!(r, vec![row("photos on tokyo", 1, 0), row("2024", 1, 1), row("sftp:host=nas", 1, 1)]);
|
||||
// NFS automounts: Linux /net/<host>, macOS /Network/Servers/<host>
|
||||
let r = rows(&["/net/tokyo/export/a.jpg", "/Network/Servers/osaka/vol/b.jpg"]);
|
||||
assert_eq!(r, vec![row("osaka", 1, 0), row("vol", 1, 1), row("tokyo", 1, 0), row("export", 1, 1)]);
|
||||
// Windows: a share by name or by address, the WSL file system, a mapped drive
|
||||
let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
vec![
|
||||
row("//192.168.1.5/scans", 1, 1),
|
||||
row("//tokyo.local/photos", 1, 1),
|
||||
row("//wsl$/Ubuntu", 1, 0),
|
||||
row("home", 1, 0),
|
||||
row("me", 1, 1),
|
||||
row("Z:", 1, 1)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_that_is_not_mounted_costs_nothing() {
|
||||
// the tree is made from the catalog's own records: it never looks at a disk, so a share that
|
||||
// is asleep or gone lists (and counts) exactly like one that is there
|
||||
let c = library(&["/Volumes/definitely-not-mounted/a/b.jpg", r"\\unreachable.invalid\share\c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
assert_eq!(tree.iter().map(|n| n.count).sum::<usize>(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_in_the_root_of_a_disk_counts_for_the_disk() {
|
||||
assert_eq!(rows(&["/x.jpg", "/d/y.jpg"]), vec![row("/", 2, 1), row("d", 1, 1)]);
|
||||
assert_eq!(rows(&["C:\\x.jpg"]), vec![row("C:", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_with_no_folder_are_left_out() {
|
||||
assert!(rows(&["x.jpg", ""]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_listed_by_name_ignoring_case() {
|
||||
let names: Vec<String> = rows(&["/p/b/1.jpg", "/p/A/2.jpg", "/p/c/3.jpg"]).into_iter().skip(2).map(|r| r.0).collect();
|
||||
assert_eq!(names, ["A", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurdly_deep_paths_neither_crash_nor_lose_photos() {
|
||||
let deep = format!("/{}/a.jpg", vec!["d"; 5000].join("/"));
|
||||
let tree = library(&[&deep]).folder_tree();
|
||||
assert_eq!(tree.iter().map(|n| n.count).sum::<usize>(), 1);
|
||||
assert!(outline(&tree).len() <= crate::folders::MAX_DEPTH + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_shows_what_was_imported_from_it_and_below() {
|
||||
let mut c = Catalog::new();
|
||||
let trip = add(&mut c, "/pics/trip/a.jpg");
|
||||
let day = add(&mut c, "/pics/trip/day1/b.jpg");
|
||||
let sibling = add(&mut c, "/pics/trip2/c.jpg"); // shares the name's start, not the folder
|
||||
let browsed = add_with(&mut c, "/pics/trip/d.jpg", |p| p.local = true);
|
||||
let f = Filter { library_folder: Some("/pics//trip/".into()), ..Default::default() };
|
||||
let shown = |id: PhotoId| f.matches(c.photo(id).unwrap(), &c);
|
||||
assert!(shown(trip) && shown(day));
|
||||
assert!(!shown(sibling), "/pics/trip2 is another folder");
|
||||
assert!(!shown(browsed), "browsed photos are not part of the library's folders");
|
||||
assert_eq!(f.describe(), "folder /pics//trip/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
|
||||
let mut c = Catalog::new();
|
||||
let gone = add_with(&mut c, "/pics/trip/a.jpg", |p| p.deleted = true);
|
||||
let kept = add(&mut c, "/pics/trip/b.jpg");
|
||||
let f = Filter { library_folder: Some("/pics/trip".into()), deleted: true, ..Default::default() };
|
||||
assert!(f.matches(c.photo(gone).unwrap(), &c));
|
||||
assert!(!f.matches(c.photo(kept).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_folder_choice_is_no_choice() {
|
||||
let c = library(&["/pics/a.jpg"]);
|
||||
let f = Filter { library_folder: Some(" ".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_row_shows_exactly_as_many_photos_as_it_says() {
|
||||
let c = library(&[
|
||||
"/Users/me/Pictures/2024/a.jpg",
|
||||
"/Users/me/Pictures/2024/b.jpg",
|
||||
"/Users/me/Pictures/2025/c.jpg",
|
||||
"/Users/me/Pictures/d.jpg",
|
||||
"/Volumes/nas/photos/e.jpg",
|
||||
"/Volumes/nas/f.jpg",
|
||||
"/x.jpg",
|
||||
"/a/../b/g.jpg",
|
||||
"D:\\Photos\\2024\\h.jpg",
|
||||
"d:/Photos/i.jpg",
|
||||
"\\\\srv\\s1\\a\\j.jpg",
|
||||
"\\\\?\\UNC\\srv\\s2\\k.jpg",
|
||||
"/pics/trip/l.jpg",
|
||||
"/pics/trip2/m.jpg",
|
||||
"/Volumes/tokyo/Photos/2024/n.jpg",
|
||||
"/Volumes/tokyo/o.jpg",
|
||||
"/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/p.jpg",
|
||||
"/net/osaka/export/q.jpg",
|
||||
r"\\wsl$\Ubuntu\home\me\r.jpg",
|
||||
// odd spellings the tree and the filter must read the same way
|
||||
"//server/share/../other/1.jpg",
|
||||
"//server/share/ok/2.jpg",
|
||||
"//a b//A/f.jpg",
|
||||
r"\\wsl$\Ubuntu\..\a\1.jpg",
|
||||
// a folder that holds other disks as well as photos of its own
|
||||
"/Volumes/1.jpg",
|
||||
"/mnt/2.jpg",
|
||||
"/mnt/disk/3.jpg",
|
||||
]);
|
||||
fn check(c: &Catalog, nodes: &[FolderNode], seen: &mut usize) {
|
||||
for n in nodes {
|
||||
// the startup volume's row only opens: its path "/" would cover every other disk
|
||||
if n.selectable {
|
||||
let f = Filter { library_folder: Some(n.path.clone()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), n.count, "{} ({})", n.name, n.path);
|
||||
*seen += 1;
|
||||
}
|
||||
check(c, &n.children, seen);
|
||||
}
|
||||
}
|
||||
let mut seen = 0;
|
||||
check(&c, &c.folder_tree(), &mut seen);
|
||||
assert!(seen > 10, "{seen} rows checked");
|
||||
}
|
||||
|
||||
fn find<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
|
||||
nodes.iter().find_map(|n| if n.path == path { Some(n) } else { find(&n.children, path) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_that_would_cover_other_disks_is_not_offered_for_choosing() {
|
||||
let tree = library(&["/Users/me/a.jpg", "/Volumes/1.jpg", "/Volumes/tokyo/x.jpg", "/mnt/2.jpg"]).folder_tree();
|
||||
let sel = |p: &str| find(&tree, p).map(|n| n.selectable);
|
||||
assert_eq!(sel("/"), Some(false), "the startup disk's path covers every disk");
|
||||
assert_eq!(sel("/Volumes"), Some(false), "it holds the tokyo disk");
|
||||
assert_eq!(sel("/Volumes/tokyo"), Some(true));
|
||||
assert_eq!(sel("/Users/me"), Some(true));
|
||||
assert_eq!(sel("/mnt"), Some(true), "no disk is mounted below it in this library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_and_the_filter_read_odd_spellings_the_same_way() {
|
||||
// `..` cannot climb above a share, and repeated separators between server and share are one
|
||||
let tree = library(&["//server/share/../other/1.jpg", "//a b//A/f.jpg"]).folder_tree();
|
||||
assert_eq!(outline(&tree), vec![row("//a b/A", 1, 1), row("//server/share", 1, 0), row("other", 1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_chooses_nothing() {
|
||||
let c = library(&["/pics/a.jpg", "C:\\b\\c.jpg"]);
|
||||
for dot in [".", "./", "a/..", "../.."] {
|
||||
let f = Filter { library_folder: Some(dot.into()), ..Default::default() };
|
||||
assert_eq!(c.query(&f, &Sort::default()).len(), 0, "{dot:?} is no folder, not everything");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_is_labelled_by_its_last_two_names() {
|
||||
use crate::folders::folder_label;
|
||||
assert_eq!(folder_label("/Volumes/tokyo/photos/travel"), "photos/travel");
|
||||
assert_eq!(folder_label("/Volumes/tokyo/"), "Volumes/tokyo");
|
||||
assert_eq!(folder_label(r"C:\Photos\2024"), "Photos/2024");
|
||||
assert_eq!(folder_label("/"), "/");
|
||||
assert_eq!(folder_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_roots_are_told_from_folders() {
|
||||
use crate::folders::{is_disk_root, is_startup_disk};
|
||||
for p in ["/", "C:", "C:\\", r"\\?\C:\", r"\\srv\share", "/Volumes/nas", "/mnt/disk", "/net/host"] {
|
||||
assert!(is_disk_root(p), "{p}");
|
||||
}
|
||||
for p in ["/Volumes", "/Users", "C:\\Photos", r"\\srv\share\x", "/Volumes/nas/p", "."] {
|
||||
assert!(!is_disk_root(p), "{p}");
|
||||
}
|
||||
assert!(is_startup_disk("/") && is_startup_disk("//") && !is_startup_disk("/Volumes/nas") && !is_startup_disk("C:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_with_spaces_at_its_edge_is_its_own_folder() {
|
||||
// legal on macOS and Linux: `/p/shoot ` is not `/p/shoot`
|
||||
let c = library(&["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"]);
|
||||
let tree = c.folder_tree();
|
||||
let shoot_space = find(&tree, "/p/shoot ").expect("its own row");
|
||||
let shoot = find(&tree, "/p/shoot").expect("and the other");
|
||||
for (node, want) in [(shoot_space, "a.jpg"), (shoot, "b.jpg")] {
|
||||
let f = Filter { library_folder: Some(node.path.clone()), ..Default::default() };
|
||||
let ids = c.query(&f, &Sort::default());
|
||||
let names: Vec<&str> = ids.iter().filter_map(|id| c.photo(*id)).map(|p| p.file_name.as_str()).collect();
|
||||
assert_eq!(names, [want], "{:?} shows its own photo", node.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_photo_never_counts_as_imported_even_when_a_disk_folder_is_also_shown() {
|
||||
let mut c = Catalog::new();
|
||||
let browsed = add_with(&mut c, "/pics/trip/b.jpg", |p| p.local = true);
|
||||
let f = Filter { folder: Some("/pics/trip".into()), library_folder: Some("/pics/trip".into()), ..Default::default() };
|
||||
assert!(!f.matches(c.photo(browsed).unwrap(), &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_check_agrees_with_the_careful_one() {
|
||||
use crate::query::{folder_key, key_within, photo_in_root};
|
||||
let paths = [
|
||||
"/a/b/c.jpg",
|
||||
"/a/b/d/e.jpg",
|
||||
"/a/bc/x.jpg",
|
||||
"/a/./b/x.jpg",
|
||||
"/a/../a/b/x.jpg",
|
||||
"/a//b/x.jpg",
|
||||
"/a\\b\\x.jpg",
|
||||
"/a/b ",
|
||||
"/a/b /x.jpg",
|
||||
"/.hidden/x.jpg",
|
||||
"/a/.b/x.jpg",
|
||||
"/x.jpg",
|
||||
"//srv/share/x.jpg",
|
||||
"C:\\a\\b\\x.jpg",
|
||||
"relative/a/b/x.jpg",
|
||||
"/Volumes/tokyo/a/x.jpg",
|
||||
"/日本/写真/x.jpg",
|
||||
];
|
||||
let roots = ["/", "/a", "/a/b", "/a/b/", "/a/bc", "/a/b ", "/Volumes/tokyo", "//srv/share", "C:\\a", "/日本", "relative", "/a/../a/b"];
|
||||
for p in paths {
|
||||
let mut c = Catalog::new();
|
||||
let id = add(&mut c, p);
|
||||
let photo = c.photo(id).unwrap();
|
||||
for r in roots {
|
||||
let root = folder_key(r);
|
||||
let slow = crate::local::folder_of(photo).is_some_and(|f| key_within(&f, &root));
|
||||
assert_eq!(photo_in_root(photo, &root), slow, "{p:?} in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ or CMYK profile converted by the CMS straight to linear Rec.2020), `Container` (
|
||||
EXIF `R03` Adobe RGB hint), `Untagged` (assumed sRGB; untagged float data assumed linear),
|
||||
`IccUnsupported` (profile present but unusable → **sRGB fallback**), `Naive` (CMYK without profile).
|
||||
|
||||
`decode_jpeg_with_fallback(bytes, opts, space)` lets an enclosing RAW container supply the colour
|
||||
space of a JPEG with no ICC or EXIF metadata. It applies the transfer curve before linear-light
|
||||
resizing; a JPEG carrying its own metadata retains its existing interpretation.
|
||||
|
||||
ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Display P3,
|
||||
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
|
||||
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
|
||||
|
||||
@@ -146,7 +146,19 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
|
||||
};
|
||||
p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)]));
|
||||
p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())]));
|
||||
align_tags(&p.encode().unwrap_or_default())
|
||||
align_tags(&fixed_date(p.encode().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Zero the header's creation date/time (ICC.1 §7.2.8, bytes 24..36) so a profile's bytes depend
|
||||
/// only on its colour space and curve. `moxcms` stamps the wall clock there on every `encode`
|
||||
/// (ignoring `ColorProfile::creation_date_time`), which made two exports of one photo differ in
|
||||
/// the embedded profile and nowhere else, so output hashes could not be compared (#180). Readers
|
||||
/// do not interpret the field; all zeros is the conventional "unspecified" value.
|
||||
fn fixed_date(mut b: Vec<u8>) -> Vec<u8> {
|
||||
if let Some(date) = b.get_mut(24..36) {
|
||||
date.fill(0);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to
|
||||
@@ -300,6 +312,23 @@ mod tests {
|
||||
let _ = SRGB;
|
||||
}
|
||||
|
||||
/// Two profiles for one space are byte-identical, and the header date is fixed: exports of the
|
||||
/// same photo with the same settings hash the same (#180).
|
||||
#[test]
|
||||
fn written_profiles_are_deterministic() {
|
||||
for n in NamedSpace::ALL {
|
||||
let a = write_named(n);
|
||||
let b = write_named(n);
|
||||
assert_eq!(a, b, "{n:?}");
|
||||
assert_eq!(&a[24..36], &[0u8; 12], "{n:?} header date/time");
|
||||
// the profile ID is "not computed", not a hash of a timestamp
|
||||
assert_eq!(&a[84..100], &[0u8; 16], "{n:?} profile ID");
|
||||
}
|
||||
let lin = write_matrix_trc(&PROPHOTO, &Trc::Linear);
|
||||
assert_eq!(lin, write_matrix_trc(&PROPHOTO, &Trc::Linear));
|
||||
assert_eq!(&lin[24..36], &[0u8; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_none() {
|
||||
assert!(parse(&[]).is_none());
|
||||
|
||||
@@ -105,6 +105,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
@@ -119,7 +123,10 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
} else {
|
||||
decode_zune(bytes, &m)?
|
||||
};
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() };
|
||||
// A raw container may carry the colour declaration of an otherwise untagged preview.
|
||||
// Apply it before linearization/resizing; existing JPEG metadata always wins.
|
||||
let hint = fallback.filter(|_| m.icc.is_none() && m.exif.is_none()).map(|space| crate::SourceSpace::named(space, crate::SpaceOrigin::Container));
|
||||
let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, hint, ..Default::default() };
|
||||
finish(F, raw, meta, (m.width, m.height), opts)
|
||||
}
|
||||
|
||||
@@ -246,6 +253,44 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn container_fallback_matches_tagged_jpeg_before_resizing_and_keeps_own_metadata() {
|
||||
use crate::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, SpaceOrigin, encode_jpeg};
|
||||
let pixels: Vec<u8> = (0..16 * 16).flat_map(|i| if i % 2 == 0 { [120, 180, 90] } else { [30, 100, 200] }).collect();
|
||||
let encode = |icc, exif| {
|
||||
encode_jpeg(
|
||||
&EncodeImage::new(16, 16, 3, Samples::U8(&pixels)),
|
||||
100,
|
||||
ChromaSubsampling::S444,
|
||||
&EncodeMeta { icc, exif, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let untagged = encode(None, None);
|
||||
let adobe = crate::icc::write_named(NamedSpace::AdobeRgb);
|
||||
let tagged = encode(Some(&adobe), None);
|
||||
for opts in [DecodeOptions::default(), DecodeOptions::fit(3, 3)] {
|
||||
let with = crate::decode_jpeg_with_fallback(&untagged, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&tagged, opts).unwrap();
|
||||
assert_eq!(with.space.origin, SpaceOrigin::Container);
|
||||
let (a, b) = (with.to_working(), expected.to_working());
|
||||
for (p, q) in a.data.iter().zip(&b.data) {
|
||||
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 0.0001), "{p:?} != {q:?}");
|
||||
}
|
||||
}
|
||||
let srgb = crate::icc::write_named(NamedSpace::Srgb);
|
||||
let exif = crate::exif::minimal_exif(6);
|
||||
for bytes in [encode(Some(&srgb), None), encode(None, Some(&exif)), encode(Some(b"bad ICC"), None)] {
|
||||
let opts = DecodeOptions::fit(5, 5);
|
||||
let with = crate::decode_jpeg_with_fallback(&bytes, opts, NamedSpace::AdobeRgb).unwrap();
|
||||
let expected = crate::decode(&bytes, opts).unwrap();
|
||||
assert_eq!(with.space, expected.space);
|
||||
assert_eq!(with.image, expected.image);
|
||||
assert_eq!(with.orientation, expected.orientation);
|
||||
}
|
||||
assert!(crate::decode_jpeg_with_fallback(b"bad JPEG", DecodeOptions::default(), NamedSpace::AdobeRgb).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_request_covers() {
|
||||
assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171));
|
||||
|
||||
@@ -156,6 +156,14 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: NamedSpace) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| jpeg::decode_with_fallback(bytes, &opts, Some(fallback)))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(Format::Jpeg, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// [`decode`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only.
|
||||
//! TIFF decode via the `tiff` crate: 1/2/4/8/16/32/64-bit integer, 16/32/64-bit float, gray/RGB/CMYK,
|
||||
//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. Palette
|
||||
//! (indexed-colour) images are refused by the `tiff` crate itself, on every read path.
|
||||
|
||||
use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
@@ -25,7 +26,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let icc = tag_bytes(&mut d, Tag::IccProfile);
|
||||
let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string());
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16);
|
||||
let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default();
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
@@ -45,6 +45,9 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if bits < 8 && planar && n_samples > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -52,6 +55,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let n = wu * hu;
|
||||
let mut buf = match res {
|
||||
DecodingResult::U8(v) if bits < 8 => Buf::U8(unpack(&v, wu, hu, n_samples, bits)?),
|
||||
DecodingResult::U8(v) => Buf::U8(v),
|
||||
DecodingResult::U16(v) => Buf::U16(v),
|
||||
// Rare integer widths: keep 16 significant bits (plenty for display-referred data).
|
||||
@@ -94,14 +98,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return finish(F, raw, meta, (w, h), opts);
|
||||
}
|
||||
|
||||
// WhiteIsZero → invert.
|
||||
if photometric == Some(0) {
|
||||
match &mut buf {
|
||||
Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x),
|
||||
Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x),
|
||||
Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x),
|
||||
}
|
||||
}
|
||||
// WhiteIsZero needs nothing here: the `tiff` crate inverts those samples as it reads them (and
|
||||
// refuses WhiteIsZero layouts it can't invert), so inverting again would show a negative.
|
||||
|
||||
// Drop extra samples beyond colour + one alpha.
|
||||
let keep = n_color + has_alpha as usize;
|
||||
@@ -140,6 +138,29 @@ fn unsupported_name(ct: ColorType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples narrower than a byte (1-bit bilevel and fax scans, 2- and 4-bit gray) arrive packed, most
|
||||
/// significant bits first, each row padded to a whole byte. Spread them out one per byte, scaled to
|
||||
/// 0–255 like any 8-bit sample.
|
||||
fn unpack(packed: &[u8], width: usize, height: usize, samples: usize, bits: u8) -> Result<Vec<u8>> {
|
||||
if !matches!(bits, 1 | 2 | 4) {
|
||||
return Err(Error::Unsupported(F, "TIFF samples of 3, 5, 6 or 7 bits"));
|
||||
}
|
||||
let bits = bits as usize;
|
||||
let per_row = width.checked_mul(samples).ok_or_else(|| err("row too wide"))?;
|
||||
let row_bytes = per_row.checked_mul(bits).ok_or_else(|| err("row too wide"))?.div_ceil(8);
|
||||
if row_bytes == 0 || packed.len() / row_bytes < height {
|
||||
return Err(err("sample buffer too short"));
|
||||
}
|
||||
let max = (1u8 << bits) - 1;
|
||||
let scale = 255 / max; // 255, 85, 17: exact
|
||||
let mut out = Vec::with_capacity(per_row.saturating_mul(height));
|
||||
for row in packed.chunks_exact(row_bytes).take(height) {
|
||||
let values = row.iter().flat_map(|&b| (1..=8 / bits).map(move |i| (b >> (8 - bits * i)) & max));
|
||||
out.extend(values.take(per_row).map(|v| v * scale));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn interleave(buf: Buf, n: usize, s: usize) -> Buf {
|
||||
fn go<T: Copy + Default>(v: Vec<T>, n: usize, s: usize) -> Vec<T> {
|
||||
if v.len() < n * s {
|
||||
|
||||
@@ -245,6 +245,80 @@ fn tiff_cmyk_and_orientation() {
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
|
||||
}
|
||||
|
||||
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
|
||||
/// already packed into `strip`, one padded row after another.
|
||||
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> {
|
||||
const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4;
|
||||
let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat();
|
||||
let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
|
||||
let mut b = b"II*\0".to_vec();
|
||||
b.extend(8u32.to_le_bytes());
|
||||
b.extend(9u16.to_le_bytes());
|
||||
b.extend(short(256, width));
|
||||
b.extend(short(257, height));
|
||||
b.extend(short(258, bits));
|
||||
b.extend(short(259, 1));
|
||||
b.extend(short(262, photometric));
|
||||
b.extend(long(273, STRIP_AT));
|
||||
b.extend(short(277, 1));
|
||||
b.extend(short(278, height));
|
||||
b.extend(long(279, strip.len() as u32));
|
||||
b.extend(0u32.to_le_bytes());
|
||||
assert_eq!(b.len(), STRIP_AT as usize);
|
||||
b.extend_from_slice(strip);
|
||||
b
|
||||
}
|
||||
|
||||
/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these
|
||||
/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative.
|
||||
#[test]
|
||||
fn tiff_white_is_zero_is_inverted_once() {
|
||||
// Two pixels: the lowest sample, then the highest, at 8 and 16 bits.
|
||||
for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] {
|
||||
for (photometric, first) in [(0, 1.0), (1, 0.0)] {
|
||||
let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap();
|
||||
let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]);
|
||||
assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or
|
||||
/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short".
|
||||
#[test]
|
||||
fn tiff_samples_narrower_than_a_byte() {
|
||||
let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1];
|
||||
|
||||
// 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored).
|
||||
// Row 0 alternates set/clear bits; row 1 is clear but for its last pixel.
|
||||
let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111];
|
||||
// What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0).
|
||||
for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] {
|
||||
let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1));
|
||||
assert!(d.grayscale);
|
||||
let clear_bit = 1.0 - set_bit;
|
||||
for x in 0..10 {
|
||||
let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit });
|
||||
assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}");
|
||||
assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding).
|
||||
let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 85, 170, 255].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}");
|
||||
}
|
||||
let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap();
|
||||
for (x, v) in [0, 255, 136].into_iter().enumerate() {
|
||||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}");
|
||||
}
|
||||
|
||||
// A strip shorter than its rows is an error, never a panic.
|
||||
assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_lossless_exact() {
|
||||
let img = gradient(31, 19);
|
||||
|
||||
+17
-6
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
|
||||
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
|
||||
//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
|
||||
//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green.
|
||||
//! A positive correction neutralises a green illuminant above the locus, so its source white uses
|
||||
//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast.
|
||||
|
||||
use crate::{Mat3, RgbSpace, Xy, bradford};
|
||||
|
||||
@@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) {
|
||||
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
|
||||
}
|
||||
|
||||
/// Chromaticity of the white point for temperature `t` (K) and `tint`.
|
||||
/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`.
|
||||
/// Positive tint means a green source white, whose correction adds magenta to the image.
|
||||
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
|
||||
let (u, v) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = -tint / TINT_SCALE;
|
||||
let duv = tint / TINT_SCALE;
|
||||
uv_to_xy(u + nu * duv, v + nv * duv)
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
|
||||
let (lu, lv) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = (u - lu) * nu + (v - lv) * nv;
|
||||
(t, -duv * TINT_SCALE)
|
||||
(t, duv * TINT_SCALE)
|
||||
}
|
||||
|
||||
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
|
||||
@@ -141,10 +144,18 @@ mod tests {
|
||||
let warm = temp_tint_to_xy(3000.0, 0.0);
|
||||
let cool = temp_tint_to_xy(9000.0, 0.0);
|
||||
assert!(warm.x > cool.x);
|
||||
// positive tint is magenta: lower v
|
||||
// A positive magenta correction specifies a greener source illuminant: higher v.
|
||||
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
|
||||
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
|
||||
assert!(v1 < v0);
|
||||
assert!(v1 > v0);
|
||||
}
|
||||
|
||||
/// Daylight lies above the Planckian locus, so D65 reads as a small positive tint, as
|
||||
/// Lightroom's Daylight preset (5500 K, +10) has it (#188).
|
||||
#[test]
|
||||
fn daylight_has_a_positive_tint() {
|
||||
let (_, tint) = xy_to_temp_tint(crate::D65);
|
||||
assert!((5.0..15.0).contains(&tint), "{tint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -158,6 +158,7 @@ impl WbMode {
|
||||
pub struct WhiteBalance {
|
||||
pub mode: WbMode,
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
@@ -539,8 +540,9 @@ impl Default for Optics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG file (`OpcodeList3`: `WarpRectilinear`, `FixVignetteRadial`), converted
|
||||
/// to the oriented, default-cropped image. This is camera/file data (stored on the photo record, not in the develop
|
||||
/// Lens corrections embedded in the file, converted to the oriented, default-cropped image: a DNG's `OpcodeList3`
|
||||
/// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic /
|
||||
/// Leica RW2 distortion, `lightcraft_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop
|
||||
/// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts.
|
||||
/// LightCraft never uses Adobe LCP lens profiles.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -30,6 +30,8 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
ab_glyph = { workspace = true }
|
||||
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
lightcraft-segment = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -93,7 +93,9 @@ fn main() {
|
||||
for path in &args {
|
||||
let bytes = std::fs::read(path).expect("read");
|
||||
println!("{path}");
|
||||
let raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
|
||||
// as the loader does: lens corrections are applied by the pipeline, not in the raw develop
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name()));
|
||||
println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
|
||||
println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm"))));
|
||||
|
||||
@@ -60,9 +60,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
|
||||
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
|
||||
let mut reference = decoded.to_working();
|
||||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||||
@@ -102,7 +101,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference)?;
|
||||
let (pairs, _) = collect_pairs(&sensor, &reference, None)?;
|
||||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||||
}
|
||||
|
||||
@@ -150,10 +149,31 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
fit_pairs_with(sensor, reference, None)
|
||||
}
|
||||
|
||||
/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges).
|
||||
fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||||
let (w, h) = (image.width, image.height);
|
||||
if w == 0 {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
let (mut lo, mut hi) = (f32::INFINITY, 0f32);
|
||||
for row in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for col in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p));
|
||||
lo = lo.min(l);
|
||||
hi = hi.max(l);
|
||||
}
|
||||
}
|
||||
if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY }
|
||||
}
|
||||
|
||||
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
|
||||
/// (for the chroma curve); `None` when too few, or the photo has too little colour.
|
||||
/// (for the chroma curve); `None` when too few, or the photo has too little colour. With
|
||||
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
|
||||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||||
/// colours (see [`EDGE_CONTRAST`]).
|
||||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)> {
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -161,7 +181,10 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<(Pairs, Pairs)>
|
||||
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
|
||||
let mut bright = Vec::new();
|
||||
let mut colour = 0;
|
||||
for (input, output) in sensor.data.iter().zip(&reference.data) {
|
||||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||||
continue;
|
||||
}
|
||||
let y = luminance_2020(*output);
|
||||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||||
continue;
|
||||
@@ -215,11 +238,25 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||||
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
|
||||
}
|
||||
|
||||
/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt
|
||||
/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
|
||||
/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px
|
||||
/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched
|
||||
/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction
|
||||
/// off). Away from edges: 0.049. Issue #232.
|
||||
const EDGE_CONTRAST: f32 = 3.0;
|
||||
|
||||
/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
|
||||
/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
|
||||
/// and chroma curve fitted to this photo.
|
||||
/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates,
|
||||
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
|
||||
/// valid when the camera JPEG's geometry differs slightly from the raw's.
|
||||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
let (pairs, bright) = collect_pairs(sensor, reference)?;
|
||||
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
|
||||
}
|
||||
|
||||
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, edge_limit)?;
|
||||
let candidates = match colour {
|
||||
Some(given) => vec![given],
|
||||
None => {
|
||||
@@ -270,9 +307,10 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
|
||||
.sum();
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)",
|
||||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})",
|
||||
(before / samples as f64).sqrt(),
|
||||
(after / samples as f64).sqrt()
|
||||
(after / samples as f64).sqrt(),
|
||||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||||
);
|
||||
}
|
||||
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||||
@@ -740,7 +778,7 @@ mod tests {
|
||||
let mut pool = Vec::new();
|
||||
for seed in 0..4 {
|
||||
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
|
||||
pool.extend(collect_pairs(&sensor, &reference).unwrap().0);
|
||||
pool.extend(collect_pairs(&sensor, &reference, None).unwrap().0);
|
||||
}
|
||||
let (matrix, _) = fit_profile(&pool).unwrap();
|
||||
// the camera's colours (luminance-normalised: the tone curve sets brightness)
|
||||
@@ -818,6 +856,68 @@ mod tests {
|
||||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||||
}
|
||||
|
||||
/// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is
|
||||
/// distortion-corrected, and on its glass façade the fit on all pixels misses the gate
|
||||
/// (held-out RMS 0.113): it used to open grey with the neutral fallback.
|
||||
#[test]
|
||||
fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() {
|
||||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||||
}
|
||||
|
||||
/// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a
|
||||
/// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree
|
||||
/// everywhere away from the edges, and the look must still be found there.
|
||||
#[test]
|
||||
fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let (w, h, block, shift) = (96, 64, 8, 2);
|
||||
// Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour.
|
||||
let scene = |x: usize, y: usize| -> [f32; 3] {
|
||||
let (bx, by) = (x / block, y / block);
|
||||
let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 };
|
||||
[ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)]
|
||||
};
|
||||
let camera = |p: [f32; 3]| {
|
||||
let q = known.apply_f32(p);
|
||||
let y = luminance_2020(q);
|
||||
q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
|
||||
};
|
||||
let mut sensor = Rgb32f::new(w, h);
|
||||
let mut reference = sensor.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
sensor.data[y * w + x] = scene(x, y);
|
||||
// The JPEG shows the scene `shift` pixels to the right of where the raw has it.
|
||||
reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y));
|
||||
}
|
||||
}
|
||||
let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges");
|
||||
// The look itself is right: on the aligned scene it reproduces the camera.
|
||||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||||
let (mut error, mut n) = (0.0, 0);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone);
|
||||
let t = camera(scene(x, y));
|
||||
error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::<f64>();
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt());
|
||||
// Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well.
|
||||
reference.data.fill([0.2; 3]);
|
||||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||||
let mut sensor = Rgb32f::new(32, 32);
|
||||
|
||||
@@ -199,7 +199,9 @@ impl Pool {
|
||||
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
|
||||
/// NEF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
|
||||
@@ -251,6 +251,9 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result
|
||||
|
||||
/// After a folder moved from `from` to `to`: a browsed folder at or below it follows.
|
||||
pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
// a library folder chosen at or below it follows too, as a source or as a filter
|
||||
follow_library_folder(&mut s.library_folder, from, to);
|
||||
follow_library_folder(&mut s.filter.library_folder, from, to);
|
||||
if let Some(b) = &mut s.browse
|
||||
&& let Ok(rel) = Path::new(&b.path).strip_prefix(from)
|
||||
{
|
||||
@@ -260,6 +263,21 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A chosen library folder at or below `from` becomes the same place below `to`. Compared the
|
||||
/// way the filter does, by folder identity, so a differently spelled choice is not widened to
|
||||
/// the folder itself.
|
||||
fn follow_library_folder(chosen: &mut Option<String>, from: &str, to: &str) {
|
||||
let Some(c) = chosen.as_deref() else { return };
|
||||
let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from));
|
||||
if fk.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) {
|
||||
let rest = rest.trim_start_matches('/');
|
||||
*chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink
|
||||
/// the photos inside, as one undo step: undo renames the folder back and restores the photos'
|
||||
/// paths, redo repeats the move — each refused (and reported) when the destination is taken.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param};
|
||||
@@ -37,6 +37,32 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
if o.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
}
|
||||
|
||||
/// A width × height lock stored as hundredths. Rejects non-finite, non-positive and
|
||||
/// extreme (beyond 20:1) shapes, which would give an empty or unusable crop.
|
||||
fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
|
||||
let ratio = x / y;
|
||||
if !(x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x > 0.0
|
||||
&& y > 0.0
|
||||
&& ratio.is_finite()
|
||||
&& (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&ratio))
|
||||
{
|
||||
return Err(bad(c, "aspect needs two positive numbers with a ratio between 1:20 and 20:1"));
|
||||
}
|
||||
let to = |v: f64| (v * 100.0).round();
|
||||
let (a, b) = (to(x), to(y));
|
||||
if a < 1.0 || b < 1.0 || a > f64::from(u32::MAX) || b > f64::from(u32::MAX) {
|
||||
return Err(bad(c, "aspect numbers are out of range"));
|
||||
}
|
||||
Ok((a as u32, b as u32))
|
||||
}
|
||||
|
||||
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
|
||||
d.crop.aspect = aspect;
|
||||
let a = aspect.map(|(aw, ah)| {
|
||||
@@ -395,6 +421,46 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"crop.drag",
|
||||
"Drag Crop Handle",
|
||||
[],
|
||||
None,
|
||||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.drag")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let handle = match p.get("handle") {
|
||||
Some(Value::String(n)) => CropHandle::from_name(n),
|
||||
Some(v) => v.as_u64().and_then(|i| u8::try_from(i).ok()).and_then(CropHandle::from_index),
|
||||
None => None,
|
||||
}
|
||||
.ok_or_else(|| bad("crop.drag", "unknown handle"))?;
|
||||
let point = |k: &str| -> Result<Point> {
|
||||
match numbers(p.get(k)).as_deref() {
|
||||
Some([x, y]) if x.is_finite() && y.is_finite() => Ok(Point::new(*x, *y)),
|
||||
_ => Err(bad("crop.drag", format!("`{k}` needs [x, y]"))),
|
||||
}
|
||||
};
|
||||
let (from, to) = (point("from")?, point("to")?);
|
||||
let given = match (p.get("start"), numbers(p.get("start")).as_deref()) {
|
||||
(None | Some(Value::Null), _) => None,
|
||||
(_, Some(&[x0, y0, x1, y1])) if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => {
|
||||
Some(Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)))
|
||||
}
|
||||
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
|
||||
};
|
||||
edit(s, "crop.drag", "Crop", |d| {
|
||||
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
|
||||
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
|
||||
// orientation is reinterpreted per image, so the frame itself is the truth.
|
||||
let ratio = d.crop.aspect.and(Some(start.rect_px(w, h).aspect())).filter(|a| a.is_finite() && *a > 0.0);
|
||||
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"crop.straighten",
|
||||
"Straighten",
|
||||
@@ -470,11 +536,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (x, y) = a.split_once('x').ok_or_else(|| bad("crop.aspect", "use WxH"))?;
|
||||
let (x, y): (f64, f64) =
|
||||
(x.parse().map_err(|_| bad("crop.aspect", "bad number"))?, y.parse().map_err(|_| bad("crop.aspect", "bad number"))?);
|
||||
Some(((x * 100.0) as u32, (y * 100.0) as u32))
|
||||
}
|
||||
Some(Value::Array(a)) if a.len() == 2 => {
|
||||
Some(((a[0].as_f64().unwrap_or(1.0) * 100.0) as u32, (a[1].as_f64().unwrap_or(1.0) * 100.0) as u32))
|
||||
Some(aspect_tuple("crop.aspect", x, y)?)
|
||||
}
|
||||
Some(v @ Value::Array(_)) => match numbers(Some(v)).as_deref() {
|
||||
Some(&[x, y]) => Some(aspect_tuple("crop.aspect", x, y)?),
|
||||
_ => return Err(bad("crop.aspect", "use [w, h] with two numbers")),
|
||||
},
|
||||
_ => return Err(bad("crop.aspect", "missing aspect")),
|
||||
};
|
||||
edit(s, "crop.aspect", "Crop Aspect", |d| {
|
||||
|
||||
@@ -23,7 +23,8 @@ impl Session {
|
||||
.into_iter()
|
||||
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
|
||||
let mut out = preset.params;
|
||||
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
|
||||
let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
|
||||
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
|
||||
o.insert(k.clone(), v.clone());
|
||||
@@ -54,6 +55,8 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
|
||||
}
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen
|
||||
crate::export::ExportOptions::validate(ID, ¶ms)?;
|
||||
let preset = ExportPreset { name: name.to_string(), params };
|
||||
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = preset,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
|
||||
//! metadata, albums, import.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
|
||||
@@ -66,7 +66,14 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
{
|
||||
return Err(bad("library.import", format!("unknown metadata preset `{n}`")));
|
||||
}
|
||||
let on_deleted = match str_param(p, "onDeleted") {
|
||||
Some(v) => {
|
||||
crate::import::OnDeleted::parse(v).ok_or_else(|| bad("library.import", format!("unknown onDeleted `{v}` (skip|restore|fresh)")))?
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
let opts = crate::import::ImportOptions {
|
||||
on_deleted,
|
||||
mode,
|
||||
preset,
|
||||
keywords: strs(p, "keywords"),
|
||||
@@ -87,7 +94,9 @@ pub fn import_params(s: &Session, p: &Value) -> Result<ImportRequest> {
|
||||
/// `album`).
|
||||
pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, mut album: Option<u64>, album_name: Option<&str>) -> Result<Value> {
|
||||
let mut report = serde_json::to_value(report).unwrap_or_default();
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
// photos an import restored from Recently Deleted take part like new ones (album, selection)
|
||||
let imported: Vec<u64> =
|
||||
["imported", "restored"].iter().flat_map(|k| report[*k].as_array().into_iter().flatten().filter_map(Value::as_u64)).collect();
|
||||
if album.is_none()
|
||||
&& let Some(name) = album_name
|
||||
&& !imported.is_empty()
|
||||
@@ -251,7 +260,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show Source",
|
||||
[],
|
||||
None,
|
||||
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}",
|
||||
"{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}",
|
||||
always,
|
||||
|s, p| {
|
||||
let kind = str_param(p, "kind").unwrap_or("all");
|
||||
@@ -268,6 +277,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
LibrarySource::Album(a)
|
||||
}
|
||||
"libraryFolder" => {
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty() && !lightcraft_catalog::query::folder_key(d).is_empty());
|
||||
let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?;
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(bad("library.source", "the startup disk's path covers every disk: choose a folder in it"));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(bad("library.source", format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if covers_other_disks(s, path, &ids) {
|
||||
return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one")));
|
||||
}
|
||||
s.library_folder = Some(path.to_string());
|
||||
// one folder at a time: a folder filter left over would be ignored
|
||||
s.filter.library_folder = None;
|
||||
LibrarySource::LibraryFolder
|
||||
}
|
||||
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
|
||||
};
|
||||
let vis = s.visible_cloned();
|
||||
@@ -282,15 +310,75 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Filter",
|
||||
[],
|
||||
None,
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}",
|
||||
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, p);
|
||||
s.filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?;
|
||||
let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?;
|
||||
let mut f = f;
|
||||
// a blank folder is no folder: no hidden "filters active" state
|
||||
f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty());
|
||||
if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {
|
||||
return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));
|
||||
}
|
||||
s.filter = f;
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
query "library.folders",
|
||||
"Library Folders",
|
||||
[],
|
||||
None,
|
||||
"{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)",
|
||||
always,
|
||||
|s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default())
|
||||
),
|
||||
cmd!(
|
||||
"library.removeFolder",
|
||||
"Remove Folder from Library",
|
||||
[],
|
||||
None,
|
||||
"{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}",
|
||||
always,
|
||||
|s, p| {
|
||||
const C: &str = "library.removeFolder";
|
||||
let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
if lightcraft_catalog::query::folder_key(path).is_empty() {
|
||||
return Err(bad(C, format!("{path}: not a folder")));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_startup_disk(path) {
|
||||
return Err(bad(C, "choose a folder, not the whole startup disk"));
|
||||
}
|
||||
if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) {
|
||||
return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it")));
|
||||
}
|
||||
let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };
|
||||
let ids = s.catalog.query(&f, &Sort::default());
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, format!("{path}: no photo in the library was imported from it")));
|
||||
}
|
||||
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
|
||||
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
|
||||
}
|
||||
let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
|
||||
s.commit("Remove Folder from Library", Op::Batch { ops })?;
|
||||
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
||||
s.filter.library_folder = None;
|
||||
}
|
||||
// the folder being shown is gone: back to everything, as for a deleted album
|
||||
if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
||||
s.library_folder = None;
|
||||
if s.source == LibrarySource::LibraryFolder {
|
||||
s.source = LibrarySource::All;
|
||||
}
|
||||
}
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
Ok(json!({"removed": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
|
||||
s.filter = Default::default();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
@@ -356,36 +444,50 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- selection
|
||||
cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
s.end_interaction()?;
|
||||
match mode {
|
||||
"replace" => {
|
||||
s.selection =
|
||||
Selection { ids: ids.clone(), active: p.get("active").and_then(Value::as_u64).map(PhotoId).or(ids.first().copied()) };
|
||||
cmd!(
|
||||
"library.select",
|
||||
"Select Photos",
|
||||
[],
|
||||
None,
|
||||
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
|
||||
always,
|
||||
|s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
// An id that is not in the library is an error, and the selection stays as it was:
|
||||
// selecting it would otherwise report success and leave the session with no usable
|
||||
// active photo (#182).
|
||||
if let Some(id) = ids.iter().chain(active.as_ref()).find(|id| s.catalog.photo(**id).is_none()) {
|
||||
return Err(bad("library.select", format!("no such photo {}", id.0)));
|
||||
}
|
||||
"add" => {
|
||||
for id in ids {
|
||||
if !s.selection.contains(id) {
|
||||
s.selection.ids.push(id);
|
||||
s.end_interaction()?;
|
||||
match mode {
|
||||
"replace" => {
|
||||
s.selection = Selection { ids: ids.clone(), active: active.or(ids.first().copied()) };
|
||||
}
|
||||
"add" => {
|
||||
for id in ids {
|
||||
if !s.selection.contains(id) {
|
||||
s.selection.ids.push(id);
|
||||
}
|
||||
s.selection.active = Some(id);
|
||||
}
|
||||
s.selection.active = Some(id);
|
||||
}
|
||||
}
|
||||
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
||||
"range" => {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(id) = ids.first() {
|
||||
s.selection.extend_to(*id, &vis);
|
||||
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
|
||||
"range" => {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(id) = ids.first() {
|
||||
s.selection.extend_to(*id, &vis);
|
||||
}
|
||||
}
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
}
|
||||
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}
|
||||
s.active_mask = None;
|
||||
s.active_spot = None;
|
||||
Ok(json!({"selected": s.selection.ids.len()}))
|
||||
}),
|
||||
),
|
||||
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };
|
||||
@@ -560,10 +662,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
id,
|
||||
deleted: false
|
||||
}))),
|
||||
cmd!(
|
||||
"library.emptyRecentlyDeleted",
|
||||
"Empty Recently Deleted",
|
||||
["Photo"],
|
||||
None,
|
||||
"{} → {deleted} — removes every photo in Recently Deleted from the library (files on disk stay)",
|
||||
always,
|
||||
|s, _| {
|
||||
let ids: Vec<PhotoId> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"deleted": 0}));
|
||||
}
|
||||
let op = s.catalog.delete_photos_permanently_ops(&ids);
|
||||
s.commit("Empty Recently Deleted", op)?;
|
||||
s.selection = Selection::default();
|
||||
Ok(json!({"deleted": ids.len()}))
|
||||
}
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let t = s.targets(p);
|
||||
let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::<Vec<_>>();
|
||||
s.commit("Delete Permanently", Op::Batch { ops })?;
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
s.selection = Selection::default();
|
||||
Ok(json!({"deleted": t.len()}))
|
||||
}),
|
||||
@@ -725,8 +845,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
} else {
|
||||
let base = if bool_or(p, "replace", false) { Default::default() } else { cur };
|
||||
merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let folder = cur.library_folder.clone();
|
||||
let base = if replace { Default::default() } else { cur };
|
||||
let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?;
|
||||
// the rules dialog has no folder field: replacing its rules keeps the folder
|
||||
// unless the call says (`libraryFolder: null`) to drop it
|
||||
if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() {
|
||||
r.library_folder = folder;
|
||||
}
|
||||
r
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
@@ -956,7 +1084,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Photos",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy|move (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/; move = as copy, then each original and its XMP sidecars are removed from the source — only after the copy is verified (a hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate and unchecked files keep their sources; a taken name gets -1, -2…; undo removes the photos from the library but leaves the files at the destination), destination?: folder for copies / moves, organize?: date (YYYY/YYYY-MM-DD) | month (YYYY/YYYY-MM) | flat | a folder template, e.g. `{date:%Y}/{date:%Y%m%d}` → 2026/20260114 (the template's `/` make the folders, each level expanded with the rename tokens and made a safe folder name: never outside the destination; must be relative, no `..`; a level with missing metadata is `unknown`) — dated by capture time, else the import time, rename?: file-name template for copies, original extension added (tokens: {name} {num} {seq} {seq:N} {date} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext}; photo.renameTokens explains each; blank = keep names), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG; copy only), local?: bool (browsing: the photos stay out of the library, like library.browse; not with move), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..], onDeleted?: skip|restore|fresh (a file that is in Recently Deleted: skip = leave it there and say so, restore = bring the photo back with its edits, fresh = delete the trashed record and import the file as new)} → {imported, duplicates: [{path, existing, reason, existingDeleted?}], restored?: [photoId], failed, moved?: [{from, to, sidecars?}], kept?: [{path, reason}] (move: sources left in place and why), album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let req = import_params(s, p)?;
|
||||
@@ -967,9 +1095,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let report = crate::import::import_with(s, &req.paths, &req.opts)?;
|
||||
let report = import_batch_done(s, report, req.album, req.album_name.as_deref())?;
|
||||
// one undo step for the whole import
|
||||
let imported: Vec<u64> = report["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
let ids_of = |k: &str| -> Vec<u64> { report[k].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default() };
|
||||
let (imported, restored) = (ids_of("imported"), ids_of("restored"));
|
||||
let n = s.undo.len().saturating_sub(undo0);
|
||||
s.merge_undo(n, &format!("Add {} Photo{}", imported.len(), if imported.len() == 1 { "" } else { "s" }));
|
||||
let (verb, count) = if imported.is_empty() && !restored.is_empty() { ("Restore", restored.len()) } else { ("Add", imported.len()) };
|
||||
s.merge_undo(n, &format!("{verb} {count} Photo{}", if count == 1 { "" } else { "s" }));
|
||||
let imported = if imported.is_empty() { restored } else { imported };
|
||||
if let Some(f) = imported.first() {
|
||||
s.selection = Selection::single(PhotoId(*f));
|
||||
}
|
||||
@@ -1051,6 +1182,16 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup
|
||||
/// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of
|
||||
/// one disk but a way to reach several.
|
||||
fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool {
|
||||
ids.iter()
|
||||
.filter_map(|id| s.catalog.photo(*id))
|
||||
.filter_map(|p| lightcraft_catalog::folders::volume_of(p))
|
||||
.any(|v| v != "/" && !lightcraft_catalog::query::folder_within(path, &v))
|
||||
}
|
||||
|
||||
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
||||
/// its rules with the filter bar's settings on top.
|
||||
fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
@@ -1077,6 +1218,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
f.deleted = false;
|
||||
if s.source == LibrarySource::LibraryFolder {
|
||||
f.library_folder = s.library_folder.clone();
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use lightcraft_develop::{CONTROLS, controls};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_active};
|
||||
use crate::Session;
|
||||
use crate::{LibrarySource, Session};
|
||||
|
||||
pub fn photo_summary(p: &Photo) -> Value {
|
||||
json!({
|
||||
@@ -48,8 +48,13 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va
|
||||
v
|
||||
}
|
||||
|
||||
/// The photo a query is about: `id` when given (which must be in the library), else the active one.
|
||||
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(c, "no photo"))
|
||||
match p.get("id").and_then(Value::as_u64).map(PhotoId) {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
Some(id) => Err(bad(c, format!("no such photo {}", id.0))),
|
||||
None => s.active().ok_or_else(|| bad(c, "no photo")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -81,10 +86,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}))
|
||||
}),
|
||||
cmd!(query "library.state", "Library State", [], None, "{}", always, |s, _| {
|
||||
let label = s.source.label(&s.catalog);
|
||||
let label = match (s.source, s.library_folder.as_deref()) {
|
||||
(LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path),
|
||||
_ => s.source.label(&s.catalog),
|
||||
};
|
||||
let n = s.visible().len();
|
||||
Ok(json!({
|
||||
"source": s.source,
|
||||
"libraryFolder": s.library_folder.as_ref().filter(|_| s.source == LibrarySource::LibraryFolder),
|
||||
"sourceLabel": label,
|
||||
"filter": s.filter,
|
||||
"sort": s.sort,
|
||||
|
||||
@@ -258,6 +258,35 @@ fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::F
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The font's vertical form, positioned from its vertical origin within the watermark cell.
|
||||
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
|
||||
fn vertical_watermark_glyph(
|
||||
font: &ab_glyph::FontRef<'_>,
|
||||
data: &harfrust::ShaperData,
|
||||
ch: char,
|
||||
px: f32,
|
||||
left: f32,
|
||||
top: f32,
|
||||
) -> Option<ab_glyph::Glyph> {
|
||||
use ab_glyph::{Font, ScaleFont};
|
||||
let face = harfrust::FontRef::new(font.font_data()).ok()?;
|
||||
let mut buf = harfrust::UnicodeBuffer::new();
|
||||
buf.add(ch, 0);
|
||||
buf.set_direction(harfrust::Direction::TopToBottom);
|
||||
buf.guess_segment_properties();
|
||||
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
|
||||
if shaped.glyph_infos().len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let gid = u16::try_from(shaped.glyph_infos().first()?.glyph_id).ok().filter(|g| *g != 0)?;
|
||||
let pos = shaped.glyph_positions().first()?;
|
||||
let sf = font.as_scaled(px);
|
||||
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
|
||||
let height = -(pos.y_advance as f32) * sy;
|
||||
let pen = ab_glyph::point(left + px / 2.0 + pos.x_offset as f32 * sx, top + (px - height) / 2.0 - pos.y_offset as f32 * sy);
|
||||
Some(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen))
|
||||
}
|
||||
|
||||
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
|
||||
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
|
||||
let width = img.width;
|
||||
@@ -312,6 +341,11 @@ fn watermark_coverage(
|
||||
}
|
||||
let fonts = watermark_fonts(craft);
|
||||
let Some(latin) = fonts.first() else { return };
|
||||
let vertical_data: Vec<_> = if wm.vertical {
|
||||
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let short = width.min(height) as f32;
|
||||
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
|
||||
let mut glyphs = Vec::new();
|
||||
@@ -335,6 +369,21 @@ fn watermark_coverage(
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
if wm.vertical
|
||||
&& matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60)
|
||||
&& let Some(face) = fonts.iter().position(|font| font.glyph_id(original).0 != 0)
|
||||
&& let (Some(font), Some(Some(data))) = (fonts.get(face), vertical_data.get(face))
|
||||
{
|
||||
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
|
||||
if let Some(glyph) = vertical_watermark_glyph(font, data, original, px, left, y)
|
||||
&& (glyph.id != font.glyph_id(original)
|
||||
|| !matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】'))
|
||||
{
|
||||
glyphs.push((face, glyph, None));
|
||||
y += px;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let vertical_form = match original {
|
||||
'、' => '︑',
|
||||
'。' => '︒',
|
||||
@@ -562,12 +611,264 @@ impl Default for ExportOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `app.export` params that are not export options: which photos, where to, a preset to start
|
||||
/// from, and the desktop app's background flag.
|
||||
pub const TARGET_PARAMS: &[&str] = &["id", "ids", "path", "dir", "preset", "background"];
|
||||
|
||||
/// The export option params [`ExportOptions::from_json`] reads (and [`ExportOptions::to_json`] writes).
|
||||
pub const OPTION_PARAMS: &[&str] = &[
|
||||
"format",
|
||||
"quality",
|
||||
"resize",
|
||||
"longEdge",
|
||||
"shortEdge",
|
||||
"width",
|
||||
"height",
|
||||
"megapixels",
|
||||
"percent",
|
||||
"dontEnlarge",
|
||||
"ppi",
|
||||
"limitKb",
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"startNumber",
|
||||
"subfolder",
|
||||
"conflict",
|
||||
"tiffCompression",
|
||||
"dngCompression",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
];
|
||||
|
||||
/// The keys of a `watermark` object ([`Watermark`], camelCase).
|
||||
pub const WATERMARK_PARAMS: &[&str] = &["text", "vertical", "size", "opacity", "anchor", "inset", "color", "shadow", "image", "imageWidth"];
|
||||
|
||||
/// Text height as a fraction of the short edge: the range the renderer draws without clamping.
|
||||
pub const WATERMARK_SIZE_RANGE: (f32, f32) = (0.005, 0.5);
|
||||
/// Margin as a fraction of the short edge.
|
||||
pub const WATERMARK_INSET_RANGE: (f32, f32) = (0.0, 0.4);
|
||||
/// A graphic's width as a fraction of the photo's width.
|
||||
pub const WATERMARK_IMAGE_WIDTH_RANGE: (f32, f32) = (0.01, 1.0);
|
||||
|
||||
/// Levenshtein distance, for "did you mean" on a misspelled key (both inputs are short).
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
let mut cur = vec![i + 1; b.len() + 1];
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let sub = prev.get(j).copied().unwrap_or(0) + usize::from(ca != cb);
|
||||
let del = prev.get(j + 1).copied().unwrap_or(0) + 1;
|
||||
let ins = cur.get(j).copied().unwrap_or(0) + 1;
|
||||
if let Some(c) = cur.get_mut(j + 1) {
|
||||
*c = sub.min(del).min(ins);
|
||||
}
|
||||
}
|
||||
prev = cur;
|
||||
}
|
||||
prev.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `unknown parameter \`k\``, naming the closest known key when one is near.
|
||||
fn unknown_key(what: &str, k: &str, known: &[&str]) -> String {
|
||||
let near = known.iter().map(|n| (edit_distance(&k.to_ascii_lowercase(), &n.to_ascii_lowercase()), *n)).filter(|(d, _)| *d <= 2).min();
|
||||
match near {
|
||||
Some((_, n)) => format!("unknown {what} `{k}` (did you mean `{n}`?)"),
|
||||
None => format!("unknown {what} `{k}` (one of {})", known.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON integer (`92`; never `92.5` or `"92"`), as [`ExportOptions::from_json`] reads them.
|
||||
fn whole(v: &serde_json::Value) -> Option<i64> {
|
||||
v.as_i64()
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
/// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or
|
||||
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
|
||||
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
|
||||
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
|
||||
/// for good after an update.
|
||||
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
|
||||
if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) {
|
||||
w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str()));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
|
||||
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
|
||||
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
|
||||
/// are accepted and left to the caller.
|
||||
pub fn from_params(p: &serde_json::Value) -> crate::Result<Self> {
|
||||
Self::validate("app.export", p)?;
|
||||
Ok(Self::from_json(p))
|
||||
}
|
||||
|
||||
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
|
||||
/// export option ([`OPTION_PARAMS`]) or a target ([`TARGET_PARAMS`]), every value must be the
|
||||
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
|
||||
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
|
||||
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
|
||||
let known: Vec<&str> = OPTION_PARAMS.iter().chain(TARGET_PARAMS).copied().collect();
|
||||
let int = |k: &str, v: &Value, lo: i64, hi: i64| -> crate::Result<i64> {
|
||||
whole(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(|| bad(format!("`{k}` must be an integer {lo}..{hi}")))
|
||||
};
|
||||
let num = |k: &str, v: &Value, lo: f64, hi: f64| -> crate::Result<f64> {
|
||||
v.as_f64().filter(|f| f.is_finite() && (lo..=hi).contains(f)).ok_or_else(|| bad(format!("`{k}` must be a number {lo}..{hi}")))
|
||||
};
|
||||
fn string<'a>(cmd: &str, k: &str, v: &'a Value) -> crate::Result<&'a str> {
|
||||
v.as_str().ok_or_else(|| crate::cmd::bad(cmd, format!("`{k}` must be a string")))
|
||||
}
|
||||
let boolean = |k: &str, v: &Value| -> crate::Result<bool> { v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))) };
|
||||
let one_of = |k: &str, v: &Value, options: &[&str]| -> crate::Result<()> {
|
||||
let s = string(cmd, k, v)?;
|
||||
if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) }
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"format" => {
|
||||
if ExportFormat::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of jpeg|png|tiff|webp|avif|dng|original")));
|
||||
}
|
||||
}
|
||||
"quality" => {
|
||||
int(k, v, 1, 100)?;
|
||||
}
|
||||
"ppi" => {
|
||||
int(k, v, 1, u16::MAX as i64)?;
|
||||
}
|
||||
"limitKb" => {
|
||||
int(k, v, 0, u32::MAX as i64)?;
|
||||
}
|
||||
"startNumber" => {
|
||||
int(k, v, 0, 999_999)?;
|
||||
}
|
||||
"bitDepth" => {
|
||||
if !matches!(whole(v), Some(8 | 10 | 16 | 32)) {
|
||||
return Err(bad(format!("`{k}` must be 8, 10, 16 or 32")));
|
||||
}
|
||||
}
|
||||
"longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => {
|
||||
num(k, v, 0.0, 1.0e9)?;
|
||||
}
|
||||
"resize" => {
|
||||
let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) };
|
||||
if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) {
|
||||
return Err(bad(unknown_key("resize key", x, &["mode", "value", "height", "dontEnlarge"])));
|
||||
}
|
||||
serde_json::from_value::<Resize>(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?;
|
||||
}
|
||||
"dontEnlarge" | "removeLocation" | "background" => {
|
||||
boolean(k, v)?;
|
||||
}
|
||||
"naming" | "subfolder" | "path" | "dir" | "preset" => {
|
||||
string(cmd, k, v)?;
|
||||
}
|
||||
"sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?,
|
||||
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
|
||||
"conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?,
|
||||
"tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?,
|
||||
"dngCompression" => {
|
||||
serde_json::from_value::<DngCompression>(v.clone())
|
||||
.map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?;
|
||||
}
|
||||
"metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?,
|
||||
"colorSpace" => {
|
||||
if OutputSpace::parse(string(cmd, k, v)?).is_none() {
|
||||
return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020")));
|
||||
}
|
||||
}
|
||||
"watermark" => Self::validate_watermark(cmd, v)?,
|
||||
"id" => {
|
||||
int(k, v, 0, i64::MAX)?;
|
||||
}
|
||||
"ids" => {
|
||||
let ok = v.as_array().is_some_and(|a| a.iter().all(|x| x.is_u64()));
|
||||
if !ok {
|
||||
return Err(bad(format!("`{k}` must be an array of photo ids")));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(unknown_key("parameter", k, &known))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183).
|
||||
fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let obj = match v {
|
||||
Value::String(_) => return Ok(()),
|
||||
Value::Object(o) => o,
|
||||
_ => {
|
||||
return Err(bad(
|
||||
"`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let frac = |k: &str, v: &Value, (lo, hi): (f32, f32), unit: &str| -> crate::Result<()> {
|
||||
match v.as_f64() {
|
||||
Some(f) if f.is_finite() && (f64::from(lo)..=f64::from(hi)).contains(&f) => Ok(()),
|
||||
_ => Err(bad(format!("`watermark.{k}` must be a number {lo}..{hi} ({unit})"))),
|
||||
}
|
||||
};
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"text" | "image" => {
|
||||
v.as_str().ok_or_else(|| bad(format!("`watermark.{k}` must be a string")))?;
|
||||
}
|
||||
"vertical" | "shadow" => {
|
||||
v.as_bool().ok_or_else(|| bad(format!("`watermark.{k}` must be true or false")))?;
|
||||
}
|
||||
"size" => frac(k, v, WATERMARK_SIZE_RANGE, "fraction of the short edge")?,
|
||||
"opacity" => frac(k, v, (0.0, 1.0), "0 = invisible, 1 = solid")?,
|
||||
"inset" => frac(k, v, WATERMARK_INSET_RANGE, "fraction of the short edge")?,
|
||||
"imageWidth" => frac(k, v, WATERMARK_IMAGE_WIDTH_RANGE, "fraction of the photo's width")?,
|
||||
"anchor" => {
|
||||
serde_json::from_value::<Anchor>(v.clone()).map_err(|_| {
|
||||
bad("`watermark.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight".into())
|
||||
})?;
|
||||
}
|
||||
"color" => {
|
||||
let ok = v.as_array().is_some_and(|a| a.len() == 3 && a.iter().all(|c| matches!(whole(c), Some(0..=255))));
|
||||
if !ok {
|
||||
return Err(bad("`watermark.color` must be [r, g, b] with 0..255 each (sRGB)".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(unknown_key("watermark key", k, WATERMARK_PARAMS))),
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<Watermark>(v.clone()).map(|_| ()).map_err(|e| bad(format!("`watermark`: {e}")))
|
||||
}
|
||||
|
||||
/// Read options from command params (`format`, `quality`, `limitKb`, `sharpen`, `sharpenAmount`,
|
||||
/// `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`, `ppi`) and the
|
||||
/// size: one of `longEdge`, `shortEdge`, `width`, `height` (both = fit inside W × H),
|
||||
/// `megapixels`, `percent` (absent or 0 = full size), or a [`Resize`] object as `resize`; plus
|
||||
/// `dontEnlarge` (default true).
|
||||
///
|
||||
/// Lenient: anything unknown or unreadable falls back to the default, which suits stored
|
||||
/// prefs and dialog prefill. A command that is about to export uses
|
||||
/// [`ExportOptions::from_params`], which validates first.
|
||||
pub fn from_json(p: &serde_json::Value) -> Self {
|
||||
use serde_json::Value;
|
||||
let d = Self::default();
|
||||
@@ -1392,6 +1693,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() {
|
||||
use ab_glyph::Font;
|
||||
let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else {
|
||||
eprintln!("skipped: built without BIZ UDMincho from craft-fonts");
|
||||
return;
|
||||
};
|
||||
let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap();
|
||||
// BIZ UDMincho has no Unicode presentation-form characters: the old fallback drew 、。.
|
||||
assert_eq!(font.glyph_id('︑').0, 0);
|
||||
assert_eq!(font.glyph_id('︒').0, 0);
|
||||
let data = harfrust::ShaperData::new(&harfrust::FontRef::new(entry.bytes).unwrap());
|
||||
for ch in ['、', '。'] {
|
||||
let glyph = vertical_watermark_glyph(&font, &data, ch, 100.0, 0.0, 0.0).unwrap();
|
||||
assert_ne!(glyph.id, font.glyph_id(ch), "the font's vertical alternate for {ch}");
|
||||
let rect = font.outline_glyph(glyph).unwrap().px_bounds();
|
||||
assert!(rect.min.x > 50.0 && rect.max.y < 50.0, "{ch} in the upper right: {rect:?}");
|
||||
}
|
||||
let craft = Box::leak(
|
||||
vec![crate::fonts::CraftFont { family: entry.family, style: entry.style, scripts: entry.scripts, bytes: entry.bytes }].into_boxed_slice(),
|
||||
);
|
||||
let wm = Watermark {
|
||||
text: "、。".into(),
|
||||
vertical: true,
|
||||
size: 0.1,
|
||||
anchor: Anchor::TopLeft,
|
||||
inset: 0.0,
|
||||
shadow: false,
|
||||
opacity: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pixels = Vec::new();
|
||||
watermark_coverage(400, 300, &wm, craft, |x, y, k, _| {
|
||||
if k > 0.5 {
|
||||
pixels.push((x, y));
|
||||
}
|
||||
});
|
||||
assert!(!pixels.is_empty());
|
||||
for (x, y) in pixels {
|
||||
assert!(x > 15 && y % 30 < 15, "actual export coverage at the upper right of the 30 px cell: {x}, {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_watermarks_support_vertical_columns() {
|
||||
use ab_glyph::Font;
|
||||
@@ -1720,4 +2064,99 @@ mod tests {
|
||||
Err(e) => assert!(e.contains("not available"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #181: a command about to export refuses what `from_json` would silently default —
|
||||
/// unknown keys (naming the nearest known one) and values of the wrong kind or range.
|
||||
#[test]
|
||||
fn export_params_are_validated() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let e = err(json!({"longEdgee": 400}));
|
||||
assert!(e.starts_with("invalid parameters for `app.export`: unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
|
||||
assert!(err(json!({"qualty": 5})).contains("did you mean `quality`"));
|
||||
assert!(err(json!({"longEdge": "banana"})).contains("`longEdge` must be a number"));
|
||||
assert!(err(json!({"quality": 0})).contains("`quality` must be an integer 1..100"));
|
||||
assert!(err(json!({"quality": "5"})).contains("`quality` must be an integer"));
|
||||
assert!(err(json!({"quality": 92.5})).contains("`quality` must be an integer"));
|
||||
assert!(err(json!({"sharpen": "lots"})).contains("`sharpen` must be one of none|screen|matte|glossy"));
|
||||
assert!(err(json!({"format": "bmp"})).contains("`format` must be one of"));
|
||||
assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32"));
|
||||
assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of"));
|
||||
assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false"));
|
||||
assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`"));
|
||||
assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:"));
|
||||
assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids"));
|
||||
assert!(err(json!("jpeg")).contains("params must be an object"));
|
||||
assert!(
|
||||
ExportOptions::validate("export.savePreset", &json!({"qualty": 5}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.starts_with("invalid parameters for `export.savePreset`")
|
||||
);
|
||||
// everything the presets, the dialog, Export with Previous and `render --opt` send passes
|
||||
for p in builtin_presets() {
|
||||
ExportOptions::from_params(&p.params).unwrap_or_else(|e| panic!("{}: {e}", p.name));
|
||||
}
|
||||
let full = ExportOptions {
|
||||
resize: Some(Resize::default()),
|
||||
limit_kb: Some(500),
|
||||
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
|
||||
bit_depth: Some(16),
|
||||
..Default::default()
|
||||
};
|
||||
let mut j = full.to_json();
|
||||
for (k, v) in [
|
||||
("dir", json!("/tmp/out")),
|
||||
("background", json!(true)),
|
||||
("preset", json!("x")),
|
||||
("ids", json!([1, 2])),
|
||||
("id", json!(1)),
|
||||
("path", json!("a.jpg")),
|
||||
] {
|
||||
j[k] = v;
|
||||
}
|
||||
assert_eq!(ExportOptions::from_params(&j).unwrap(), full);
|
||||
assert_eq!(ExportOptions::from_params(&ExportOptions::default().to_json()).unwrap(), ExportOptions::default());
|
||||
// null is "not given", as a dropped key is
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in
|
||||
/// the `app.export` param string), instead of unknown keys passing and sizes clamping silently.
|
||||
#[test]
|
||||
fn watermark_params_are_validated() {
|
||||
use serde_json::json;
|
||||
let err = |w: serde_json::Value| ExportOptions::from_params(&json!({"watermark": w})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(
|
||||
err(json!(5))
|
||||
.contains("`watermark` must be text or an object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}")
|
||||
);
|
||||
assert!(
|
||||
err(json!({"text": "x", "bogus": 1}))
|
||||
.contains("unknown watermark key `bogus` (one of text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth)")
|
||||
);
|
||||
assert!(err(json!({"text": "x", "sizee": 0.1})).contains("did you mean `size`"));
|
||||
for size in [json!(3), json!(0.5001), json!(0), json!("big")] {
|
||||
let e = err(json!({"text": "x", "size": size}));
|
||||
assert!(e.contains("`watermark.size` must be a number 0.005..0.5 (fraction of the short edge)"), "{size}: {e}");
|
||||
}
|
||||
assert!(err(json!({"text": "x", "opacity": 1.5})).contains("`watermark.opacity` must be a number 0..1"));
|
||||
assert!(err(json!({"text": "x", "inset": -0.1})).contains("`watermark.inset` must be a number 0..0.4"));
|
||||
assert!(err(json!({"image": "logo.png", "imageWidth": 2})).contains("`watermark.imageWidth` must be a number 0.01..1"));
|
||||
assert!(err(json!({"text": "x", "anchor": "middle"})).contains("`watermark.anchor` must be one of"));
|
||||
assert!(err(json!({"text": "x", "color": [255, 255]})).contains("`watermark.color` must be [r, g, b]"));
|
||||
assert!(err(json!({"text": "x", "color": [255, 255, 256]})).contains("`watermark.color`"));
|
||||
assert!(err(json!({"text": "x", "vertical": "yes"})).contains("`watermark.vertical` must be true or false"));
|
||||
assert!(err(json!({"text": 7})).contains("`watermark.text` must be a string"));
|
||||
// the documented shapes pass and read as before
|
||||
let o = ExportOptions::from_params(&json!({"watermark": "© me"})).unwrap();
|
||||
assert_eq!(o.watermark.map(|w| w.text).as_deref(), Some("© me"));
|
||||
let w = json!({"text": "日本語", "vertical": true, "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "inset": 0.05, "color": [0, 0, 0], "shadow": false, "image": "", "imageWidth": 0.3});
|
||||
let wm = ExportOptions::from_params(&json!({"watermark": w})).unwrap().watermark.unwrap();
|
||||
assert!((wm.size - 0.1).abs() < 1e-6 && wm.vertical && wm.anchor == Anchor::TopLeft && wm.color == [0, 0, 0] && !wm.shadow);
|
||||
assert_eq!(ExportOptions::from_params(&json!({"watermark": {"text": "x", "size": 0.5}})).unwrap().watermark.map(|w| w.size), Some(0.5));
|
||||
// the Export dialog's sliders (1–15 % size, 5–100 % opacity, 2–100 % width) stay inside the accepted ranges
|
||||
assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15);
|
||||
assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0);
|
||||
}
|
||||
}
|
||||
+149
-13
@@ -43,8 +43,9 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
|
||||
/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for
|
||||
/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||||
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the
|
||||
/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the
|
||||
/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||||
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
|
||||
use lightcraft_geom::Point;
|
||||
@@ -87,10 +88,6 @@ pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop
|
||||
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
|
||||
}
|
||||
|
||||
fn is_lens_opcode(op: &lightcraft_raw::Opcode) -> bool {
|
||||
matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })
|
||||
}
|
||||
|
||||
fn ext_upper(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||||
}
|
||||
@@ -224,13 +221,14 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
drop(bytes);
|
||||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||||
let lens = embedded_lens(&raw.info());
|
||||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
let t0 = web_time::Instant::now();
|
||||
@@ -320,6 +318,21 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||||
}
|
||||
|
||||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
|
||||
lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb,
|
||||
};
|
||||
lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok()
|
||||
}
|
||||
None => lightcraft_codecs::decode(&jpeg, opts).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Oriented size of the embedded preview of a raw file.
|
||||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
@@ -333,8 +346,7 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
let d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
let img = d.to_working();
|
||||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||||
@@ -343,8 +355,7 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
|
||||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||||
let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||||
let mut d = decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32))?;
|
||||
if d.image.width.max(d.image.height) > max_edge {
|
||||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||||
d.alpha = None;
|
||||
@@ -402,6 +413,131 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||||
/// mostly cancel, edges remain.
|
||||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
let l: Vec<f32> = (0..w * h).map(|i| lum(i % w, i / w)).collect();
|
||||
let mut out = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h));
|
||||
let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::<f32>();
|
||||
out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its
|
||||
/// normalised cross-correlation, or `None` when the patch has too little detail to tell.
|
||||
fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> {
|
||||
let pa: Vec<f32> = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect();
|
||||
let na = pa.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if na < 1e-3 * p as f32 {
|
||||
return None;
|
||||
}
|
||||
let mut best = (f32::MIN, (0, 0));
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (bx, by) = (x as i64 + dx, y as i64 + dy);
|
||||
let (mut dot, mut nb) = (0.0f32, 0.0f32);
|
||||
for i in 0..p {
|
||||
for j in 0..p {
|
||||
let v = b[(by as usize + i) * w + bx as usize + j];
|
||||
dot += pa[i * p + j] * v;
|
||||
nb += v * v;
|
||||
}
|
||||
}
|
||||
let c = dot / (na * nb.sqrt()).max(1e-12);
|
||||
if c > best.0 {
|
||||
best = (c, (dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((best.1, best.0))
|
||||
}
|
||||
|
||||
/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the
|
||||
/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile
|
||||
/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide
|
||||
/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction.
|
||||
#[test]
|
||||
fn corpus_rw2_distortion_matches_the_camera_jpeg() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
|
||||
assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None);
|
||||
|
||||
// (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all)
|
||||
let files = [
|
||||
("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners
|
||||
("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 %
|
||||
("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 %
|
||||
("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 %
|
||||
("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction
|
||||
("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 %
|
||||
];
|
||||
const EDGE: usize = 640;
|
||||
const P: usize = 64;
|
||||
const R: i64 = 12;
|
||||
for (name, off_at_least) in files {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let probed = probe_bytes(name, &bytes).unwrap();
|
||||
assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read");
|
||||
let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap();
|
||||
assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree");
|
||||
// Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear`
|
||||
let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap();
|
||||
assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG");
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
let render = |s: &lightcraft_develop::DevelopSettings| {
|
||||
lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image
|
||||
};
|
||||
let before = render(&s);
|
||||
s.optics.lens_profile = true;
|
||||
let after = render(&s);
|
||||
let (w, h) = (after.width, after.height);
|
||||
assert_eq!((before.width, before.height), (w, h));
|
||||
// the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre
|
||||
let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap();
|
||||
let (cw, ch) = (cam.width as f64, cam.height as f64);
|
||||
let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) };
|
||||
let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize);
|
||||
let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell);
|
||||
let lin = |v: u8| (v as f32 / 255.0).powf(2.2);
|
||||
let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| {
|
||||
let p = im.data[y * im.width + x];
|
||||
0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2])
|
||||
};
|
||||
let reference = detail(w, h, |x, y| {
|
||||
let p = cam.data[y * w + x];
|
||||
0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2]
|
||||
});
|
||||
let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y)));
|
||||
let m = R as usize + 4;
|
||||
let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)];
|
||||
let mut measured = 0;
|
||||
for c in corners {
|
||||
let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue };
|
||||
measured += 1;
|
||||
assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}");
|
||||
if off_at_least > 0
|
||||
&& let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R)
|
||||
{
|
||||
let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3;
|
||||
assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}");
|
||||
}
|
||||
}
|
||||
assert!(measured >= 2, "{name}: only {measured} corners with detail");
|
||||
}
|
||||
}
|
||||
|
||||
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
|
||||
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||||
|
||||
@@ -48,9 +48,34 @@ pub enum ImportMode {
|
||||
Move,
|
||||
}
|
||||
|
||||
/// What an import does with a file the library already has in Recently Deleted.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum OnDeleted {
|
||||
/// Leave it there; the report says so ([`Duplicate::existing_deleted`]).
|
||||
#[default]
|
||||
Skip,
|
||||
/// Bring the trashed photo back, with its edits ([`ImportReport::restored`]).
|
||||
Restore,
|
||||
/// Delete the trashed record permanently and import the file as a new photo.
|
||||
Fresh,
|
||||
}
|
||||
|
||||
impl OnDeleted {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"skip" => Some(Self::Skip),
|
||||
"restore" => Some(Self::Restore),
|
||||
"fresh" => Some(Self::Fresh),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an import does besides adding the photos.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ImportOptions {
|
||||
/// A file that is in Recently Deleted: skip (default), restore, or import afresh.
|
||||
pub on_deleted: OnDeleted,
|
||||
pub mode: ImportMode,
|
||||
/// Applied to every imported photo (one History entry).
|
||||
pub preset: Option<lightcraft_develop::Preset>,
|
||||
@@ -147,12 +172,18 @@ pub struct Duplicate {
|
||||
pub existing: Option<u64>,
|
||||
/// `"path"` (already imported from there) or `"content"` (same bytes elsewhere).
|
||||
pub reason: &'static str,
|
||||
/// That photo is in Recently Deleted (see [`OnDeleted`]).
|
||||
#[serde(rename = "existingDeleted", skip_serializing_if = "std::ops::Not::not")]
|
||||
pub existing_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ImportReport {
|
||||
pub imported: Vec<u64>,
|
||||
pub duplicates: Vec<Duplicate>,
|
||||
/// [`OnDeleted::Restore`]: photos brought back from Recently Deleted instead of imported.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub restored: Vec<u64>,
|
||||
/// Move: the originals that were moved (and removed from the source).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub moved: Vec<Moved>,
|
||||
@@ -638,6 +669,10 @@ impl ImportJob {
|
||||
let mut by_path = HashMap::new();
|
||||
let mut by_hash = HashMap::new();
|
||||
for p in s.catalog.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && opts.on_deleted == OnDeleted::Fresh {
|
||||
continue;
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
by_path.insert(path.clone(), (p.id, p.local));
|
||||
}
|
||||
@@ -840,6 +875,20 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
let mut placed: Vec<crate::import_move::Placed> = Vec::new();
|
||||
// photos added by this batch, by content (a duplicate of a file earlier in the import names it)
|
||||
let mut new_hash: HashMap<String, PhotoId> = HashMap::new();
|
||||
// Fresh: trashed photos a new import replaces (by content or by file), each removed once
|
||||
let (mut trash_hash, mut trash_path): (HashMap<String, PhotoId>, HashMap<String, PhotoId>) = Default::default();
|
||||
if opts.on_deleted == OnDeleted::Fresh {
|
||||
for p in s.catalog.photos().filter(|p| p.deleted) {
|
||||
if let Some(h) = &p.content_hash {
|
||||
trash_hash.insert(h.clone(), p.id);
|
||||
}
|
||||
if let Source::File { path } = &p.source {
|
||||
trash_path.insert(path.clone(), p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut purge: Vec<PhotoId> = Vec::new();
|
||||
let mut purged: std::collections::HashSet<PhotoId> = Default::default();
|
||||
for it in prepared.items {
|
||||
match it {
|
||||
PreparedItem::Promote(id) => {
|
||||
@@ -853,7 +902,16 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
let h = hash?;
|
||||
new_hash.get(&h).copied().or_else(|| s.catalog.photos().find(|p| p.content_hash.as_deref() == Some(h.as_str())).map(|p| p.id))
|
||||
});
|
||||
report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason });
|
||||
let trashed = existing.filter(|id| s.catalog.photo(*id).is_some_and(|p| p.deleted));
|
||||
match trashed {
|
||||
Some(id) if opts.on_deleted == OnDeleted::Restore => {
|
||||
if !report.restored.contains(&id.0) {
|
||||
ops.push(Op::SetDeleted { id, deleted: false });
|
||||
report.restored.push(id.0);
|
||||
}
|
||||
}
|
||||
_ => report.duplicates.push(Duplicate { path, existing: existing.map(|i| i.0), reason, existing_deleted: trashed.is_some() }),
|
||||
}
|
||||
}
|
||||
PreparedItem::Failed(path, e) => report.failed.push((path, e)),
|
||||
PreparedItem::Kept(k) => report.kept.push(k),
|
||||
@@ -864,6 +922,11 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
if let Some(h) = &info.content_hash {
|
||||
new_hash.insert(h.clone(), id);
|
||||
}
|
||||
for old in info.content_hash.as_ref().and_then(|h| trash_hash.get(h)).into_iter().chain(trash_path.get(&stored)) {
|
||||
if purged.insert(*old) {
|
||||
purge.push(*old);
|
||||
}
|
||||
}
|
||||
let sidecar = sidecar.map(|sc| sc.resolve_label(&s.catalog)).filter(|sc| *sc != crate::sidecar::SidecarData::default());
|
||||
// a copy is catalogued under its new name
|
||||
let name = Path::new(&stored).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
@@ -907,9 +970,18 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare
|
||||
}
|
||||
}
|
||||
}
|
||||
// (built against the catalog as it is: one op list for all of them, ahead of the additions)
|
||||
if !purge.is_empty() {
|
||||
ops.insert(0, s.catalog.delete_photos_permanently_ops(&purge));
|
||||
}
|
||||
let log0 = s.pending_log.len();
|
||||
let (verb, n) = if report.imported.is_empty() && !report.restored.is_empty() {
|
||||
("Restore", report.restored.len())
|
||||
} else {
|
||||
("Add", report.imported.len().max(1))
|
||||
};
|
||||
if !ops.is_empty()
|
||||
&& let Err(e) = s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
&& let Err(e) = s.commit(&format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }), Op::Batch { ops })
|
||||
{
|
||||
// nothing was catalogued: take the moves back (their sources were never touched)
|
||||
placed.iter().for_each(crate::import_move::rollback);
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod guard;
|
||||
pub mod import;
|
||||
mod import_move;
|
||||
pub mod library;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
@@ -46,7 +47,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
pub use media::{RenderJob, SourceLevel};
|
||||
use serde_json::Value;
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips};
|
||||
pub use view::{Browse, FilterChip, LibrarySource, Selection, SelectionState, filter_chips};
|
||||
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -134,6 +135,9 @@ pub struct Session {
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
pub browse: Option<Browse>,
|
||||
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
|
||||
/// from it and from the folders inside it.
|
||||
pub library_folder: Option<String>,
|
||||
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
|
||||
pub meta_clipboard: Option<Value>,
|
||||
/// The photo that was active before the current one (Paste Settings from Previous).
|
||||
@@ -234,6 +238,7 @@ impl Session {
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
library_folder: None,
|
||||
previous_active: None,
|
||||
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
|
||||
presets: presets::builtin(),
|
||||
@@ -590,7 +595,8 @@ impl Session {
|
||||
|
||||
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
|
||||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
let mut key =
|
||||
(self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder));
|
||||
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()));
|
||||
@@ -605,7 +611,18 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
if self.source == LibrarySource::LibraryFolder {
|
||||
// no folder chosen: nothing (`.` names no folder)
|
||||
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
|
||||
}
|
||||
let mut visible = self.catalog.query(&f, &self.sort);
|
||||
if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
|
||||
// the shown folder lost its last photo (deleted, moved, removed): everything, not
|
||||
// an empty grid under the name of a folder that is gone from the sidebar
|
||||
self.source = LibrarySource::All;
|
||||
self.library_folder = None;
|
||||
return self.visible();
|
||||
}
|
||||
if matches!(self.source, LibrarySource::Album(_))
|
||||
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
|
||||
&& let LibrarySource::Album(a) = self.source
|
||||
@@ -642,13 +659,20 @@ impl Session {
|
||||
&self.visible
|
||||
}
|
||||
|
||||
/// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
|
||||
/// shows.
|
||||
fn folder_holds_photos(&self) -> bool {
|
||||
let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() };
|
||||
self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty()
|
||||
}
|
||||
|
||||
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
|
||||
/// them; `None` where that is not a separate number (Missing Photos).
|
||||
pub fn source_total(&mut self) -> Option<usize> {
|
||||
if self.source == LibrarySource::Missing {
|
||||
return None;
|
||||
}
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse));
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder));
|
||||
if self.total.as_ref().map(|t| &t.0) != Some(&key) {
|
||||
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
@@ -656,6 +680,9 @@ impl Session {
|
||||
f.folder = Some(b.path);
|
||||
f.subfolders = b.subfolders;
|
||||
}
|
||||
if self.source == LibrarySource::LibraryFolder {
|
||||
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
|
||||
}
|
||||
let n = self.catalog.query(&f, &self.sort).len();
|
||||
self.total = Some((key, n));
|
||||
}
|
||||
@@ -709,6 +736,8 @@ mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_forget_local;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
|
||||
@@ -102,6 +102,8 @@ struct PresetsFile {
|
||||
struct ViewFile {
|
||||
source: LibrarySource,
|
||||
browse: Option<crate::Browse>,
|
||||
/// The folder a `libraryFolder` source shows.
|
||||
library_folder: Option<String>,
|
||||
// No filter: a library opens unfiltered. A date, keyword or person left over from the last session
|
||||
// would silently hide photos, with only a small badge to say so.
|
||||
sort: lightcraft_catalog::Sort,
|
||||
@@ -309,25 +311,12 @@ impl Session {
|
||||
self.pending_log.clear();
|
||||
self.selection = Selection::default();
|
||||
self.source = LibrarySource::All;
|
||||
self.library_folder = None;
|
||||
if report.created && seed_demo {
|
||||
crate::demo::load(self);
|
||||
journal.snapshot(&self.catalog)?;
|
||||
}
|
||||
let mut settings = SettingsLoad::default();
|
||||
// presets
|
||||
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
for p in &mut self.presets {
|
||||
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||
}
|
||||
for u in f.user {
|
||||
if !self.presets.iter().any(|p| p.id == u.id) {
|
||||
self.presets.push(u);
|
||||
}
|
||||
}
|
||||
let known = |id: &String| crate::presets::profile(id).is_some();
|
||||
self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect();
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// preferences
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
@@ -349,10 +338,34 @@ impl Session {
|
||||
if let Some(d) = &self.smart_previews_dir {
|
||||
self.media.smart_dir = Some(d.clone());
|
||||
}
|
||||
// presets
|
||||
if let Some(f) = settings.read::<PresetsFile>(files.as_mut(), "presets.json") {
|
||||
for p in &mut self.presets {
|
||||
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
|
||||
}
|
||||
for u in f.user {
|
||||
if !self.presets.iter().any(|p| p.id == u.id) {
|
||||
self.presets.push(u);
|
||||
}
|
||||
}
|
||||
let known = |id: &String| crate::presets::profile(id).is_some() || self.lut_profiles.iter().any(|p| &p.id == id);
|
||||
self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect();
|
||||
self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect();
|
||||
}
|
||||
// view state
|
||||
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
|
||||
self.source = v.source;
|
||||
self.browse = v.browse;
|
||||
self.library_folder = v.library_folder.filter(|f| !f.trim().is_empty());
|
||||
if self.source == LibrarySource::LibraryFolder {
|
||||
// a folder that is gone (or none): everything, not an empty grid
|
||||
let f = lightcraft_catalog::Filter { library_folder: self.library_folder.clone(), ..Default::default() };
|
||||
let any = self.library_folder.is_some() && !self.catalog.query(&f, &lightcraft_catalog::Sort::default()).is_empty();
|
||||
if !any {
|
||||
self.source = LibrarySource::All;
|
||||
self.library_folder = None;
|
||||
}
|
||||
}
|
||||
self.sort = v.sort;
|
||||
self.selection = v.selection;
|
||||
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
|
||||
@@ -500,7 +513,14 @@ impl Session {
|
||||
}
|
||||
|
||||
fn view_json(&self) -> Vec<u8> {
|
||||
let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() };
|
||||
let view = ViewFile {
|
||||
source: self.source,
|
||||
browse: self.browse.clone(),
|
||||
// only while it is shown: a leftover would be a stale choice nobody made
|
||||
library_folder: self.library_folder.clone().filter(|_| self.source == LibrarySource::LibraryFolder),
|
||||
sort: self.sort,
|
||||
selection: self.selection.clone(),
|
||||
};
|
||||
serde_json::to_vec_pretty(&view).unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `log` records on stderr for the native desktop app and CLI (#168). The web build installs
|
||||
//! eframe's `WebLogger` instead.
|
||||
//!
|
||||
//! Warnings and errors are shown by default. `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level
|
||||
//! (`off`, `error`, `warn`, `info`, `debug`, `trace`); `LIGHTCRAFT_LOG` wins when both are set.
|
||||
//! `RUST_LOG` also accepts per-target directives (`lightcraft=debug,wgpu=warn`): a bare level
|
||||
//! applies to everything, and the most verbose `lightcraft…=level` directive sets LightCraft's.
|
||||
//! Below warnings only LightCraft's own records are shown, so `info` and `debug` aren't drowned
|
||||
//! out by the GPU and windowing libraries.
|
||||
|
||||
use log::{Level, LevelFilter, Metadata, Record};
|
||||
|
||||
/// Records shown at a level, written to stderr as `<prefix>: LEVEL target: message`.
|
||||
pub struct StderrLog {
|
||||
prefix: &'static str,
|
||||
level: LevelFilter,
|
||||
}
|
||||
|
||||
impl StderrLog {
|
||||
pub fn new(prefix: &'static str, level: LevelFilter) -> Self {
|
||||
Self { prefix, level }
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for StderrLog {
|
||||
fn enabled(&self, m: &Metadata) -> bool {
|
||||
m.level() <= self.level && (m.level() <= Level::Warn || m.target().starts_with("lightcraft"))
|
||||
}
|
||||
fn log(&self, r: &Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
eprintln!("{}: {} {}: {}", self.prefix, r.level(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" => Some(LevelFilter::Off),
|
||||
"error" => Some(LevelFilter::Error),
|
||||
"warn" | "warning" => Some(LevelFilter::Warn),
|
||||
"info" => Some(LevelFilter::Info),
|
||||
"debug" => Some(LevelFilter::Debug),
|
||||
"trace" => Some(LevelFilter::Trace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The level that `LIGHTCRAFT_LOG` / `RUST_LOG` ask for; warnings when neither names one.
|
||||
pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> LevelFilter {
|
||||
if let Some(l) = lightcraft_log.and_then(parse_level) {
|
||||
return l;
|
||||
}
|
||||
let Some(rust_log) = rust_log else { return LevelFilter::Warn };
|
||||
let mut bare = None;
|
||||
let mut ours: Option<LevelFilter> = None;
|
||||
for directive in rust_log.split(',') {
|
||||
match directive.split_once('=') {
|
||||
None => bare = parse_level(directive).or(bare),
|
||||
Some((target, level)) if target.trim().starts_with("lightcraft") => {
|
||||
if let Some(l) = parse_level(level) {
|
||||
ours = Some(ours.map_or(l, |o| o.max(l)));
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
ours.or(bare).unwrap_or(LevelFilter::Warn)
|
||||
}
|
||||
|
||||
/// Install the stderr logger at the level the environment asks for. Does nothing when a logger is
|
||||
/// already installed.
|
||||
pub fn install(prefix: &'static str) {
|
||||
let level = level_from(std::env::var("LIGHTCRAFT_LOG").ok().as_deref(), std::env::var("RUST_LOG").ok().as_deref());
|
||||
static LOGGER: std::sync::OnceLock<StderrLog> = std::sync::OnceLock::new();
|
||||
if log::set_logger(LOGGER.get_or_init(|| StderrLog::new(prefix, level))).is_ok() {
|
||||
log::set_max_level(level);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
|
||||
#[test]
|
||||
fn warnings_by_default_and_either_variable_picks_the_level() {
|
||||
assert_eq!(level_from(None, None), LevelFilter::Warn);
|
||||
assert_eq!(level_from(None, Some("")), LevelFilter::Warn);
|
||||
assert_eq!(level_from(None, Some("warn")), LevelFilter::Warn);
|
||||
assert_eq!(level_from(None, Some("TRACE")), LevelFilter::Trace);
|
||||
assert_eq!(level_from(None, Some("off")), LevelFilter::Off);
|
||||
assert_eq!(level_from(Some("info"), None), LevelFilter::Info);
|
||||
assert_eq!(level_from(Some("error"), Some("debug")), LevelFilter::Error, "LIGHTCRAFT_LOG wins");
|
||||
assert_eq!(level_from(Some("nonsense"), Some("debug")), LevelFilter::Debug, "an unknown LIGHTCRAFT_LOG is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_directives_set_lightcraft_s_level() {
|
||||
assert_eq!(level_from(None, Some("lightcraft=debug,wgpu=warn")), LevelFilter::Debug);
|
||||
assert_eq!(level_from(None, Some("wgpu_core=trace")), LevelFilter::Warn, "other crates' directives don't apply");
|
||||
assert_eq!(level_from(None, Some("error,lightcraft_gpu=info")), LevelFilter::Info);
|
||||
assert_eq!(level_from(None, Some("lightcraft_gpu=info,lightcraft_engine=trace")), LevelFilter::Trace);
|
||||
assert_eq!(level_from(None, Some("info,naga=off")), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_lightcraft_records_below_warnings() {
|
||||
let log = StderrLog::new("test", LevelFilter::Debug);
|
||||
let meta = |level, target| Metadata::builder().level(level).target(target).build();
|
||||
assert!(log.enabled(&meta(Level::Warn, "wgpu_core::device")));
|
||||
assert!(log.enabled(&meta(Level::Debug, "lightcraft_gpu::ctx")));
|
||||
assert!(!log.enabled(&meta(Level::Info, "wgpu_core::device")));
|
||||
assert!(!log.enabled(&meta(Level::Trace, "lightcraft_gpu::ctx")));
|
||||
let quiet = StderrLog::new("test", LevelFilter::Off);
|
||||
assert!(!quiet.enabled(&meta(Level::Error, "lightcraft_engine")));
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 11;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
@@ -446,6 +446,14 @@ pub fn profile(id: &str) -> Option<&'static ProfileInfo> {
|
||||
pub const RECENT_PROFILES: usize = 5;
|
||||
|
||||
impl Session {
|
||||
/// A profile by id, looking up built-in profiles first and imported LUT profiles second.
|
||||
pub fn profile_info(&self, id: &str) -> Option<(&str, &str)> {
|
||||
if let Some(p) = profile(id) {
|
||||
return Some((p.name, p.group));
|
||||
}
|
||||
self.lut_profiles.iter().find(|p| p.id == id).map(|p| (p.name.as_str(), p.group.as_str()))
|
||||
}
|
||||
|
||||
/// Remember `id` as the most recently applied profile.
|
||||
pub fn note_profile_used(&mut self, id: &str) {
|
||||
self.profile_recent.retain(|p| p != id);
|
||||
|
||||
@@ -262,7 +262,7 @@ impl Segmenter {
|
||||
if mirrors.is_empty() {
|
||||
return Err(lightcraft_segment::fetch::no_mirrors_message());
|
||||
}
|
||||
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::Options::default())
|
||||
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options())
|
||||
}
|
||||
#[cfg(not(feature = "sam"))]
|
||||
{
|
||||
|
||||
@@ -382,6 +382,61 @@ fn crop_aspect_lock_current_and_toggle() {
|
||||
assert!(active_dev(&s).crop.aspect.is_none(), "toggle unlocks");
|
||||
}
|
||||
|
||||
// Feature: dragging crop handles (issue 295)
|
||||
#[test]
|
||||
fn crop_drag_stops_at_the_image_edge_and_keeps_the_anchor() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.2, 0.2, 0.8, 0.8]})).unwrap();
|
||||
s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [1.4, 0.7]})).unwrap();
|
||||
let r = active_dev(&s).crop.geometry.rect;
|
||||
assert!((r.x0 - 0.2).abs() < 1e-6 && (r.y0 - 0.2).abs() < 1e-6, "anchor moved: {r:?}");
|
||||
assert!((r.x1 - 1.0).abs() < 1e-6 && (r.y1 - 0.7).abs() < 1e-6, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_keeps_a_locked_ratio_on_every_handle() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.aspect", &json!({"aspect": "16x9"})).unwrap();
|
||||
let d = active_dev(&s);
|
||||
assert!(d.crop.aspect.is_some());
|
||||
let want = d.crop.geometry.rect.aspect();
|
||||
let r = d.crop.geometry.rect;
|
||||
for (handle, to) in [("left", [r.x0 + 0.1, 0.5]), ("top", [0.5, r.y0 + 0.05]), ("bottomRight", [1.3, 1.3]), ("right", [0.9, 0.5])] {
|
||||
s.execute("crop.drag", &json!({"handle": handle, "from": [0.5, 0.5], "to": to, "start": [r.x0, r.y0, r.x1, r.y1]})).unwrap();
|
||||
let g = active_dev(&s).crop.geometry.rect;
|
||||
assert!((g.aspect() - want).abs() < 1e-6, "{handle}: {} vs {want}", g.aspect());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_rejects_unknown_handles_and_bad_points() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "sideways", "from": [0.5, 0.5], "to": [0.6, 0.6]})).is_err());
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5], "to": [0.6, 0.6]})).is_err());
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "to": [0.6, 0.6]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_aspect_rejects_degenerate_custom_ratios() {
|
||||
let mut s = demo();
|
||||
let before = active_dev(&s).crop;
|
||||
for bad in [json!([0.004, 3]), json!([3, 0]), json!([-1, 2]), json!([1000, 0.01]), json!(["a", 2]), json!([1, 2, 3])] {
|
||||
assert!(s.execute("crop.aspect", &json!({"aspect": bad.clone()})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("crop.aspect", &json!({"aspect": "0x5"})).is_err());
|
||||
let after = active_dev(&s).crop;
|
||||
assert_eq!(before.geometry, after.geometry, "a rejected ratio leaves the crop alone");
|
||||
s.execute("crop.aspect", &json!({"aspect": [2.5, 1]})).unwrap();
|
||||
assert!(!active_dev(&s).crop.geometry.rect.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_drag_rejects_non_numeric_points() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "from": ["a", 0.5, 0.5], "to": [0.6, 0.6]})).is_err());
|
||||
assert!(s.execute("crop.drag", &json!({"handle": "move", "from": [0.5, 0.5], "to": [0.6, 0.6], "start": [0.1, "x", 0.5, 0.5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_from_previous_and_copy_paste_metadata() {
|
||||
let mut s = demo();
|
||||
@@ -817,3 +872,53 @@ fn face_job_follows_rotate_right() {
|
||||
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
|
||||
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
|
||||
}
|
||||
|
||||
/// Selecting an id that is not in the library is an error, and the selection stays as it was
|
||||
/// (#182: `select_photos {"ids": [9999]}` answered `selected: 1` and the session lost its photo).
|
||||
#[test]
|
||||
fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() {
|
||||
let mut s = demo();
|
||||
let vis = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [vis[0].0]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
for p in [
|
||||
json!({"ids": [9999]}),
|
||||
json!({"ids": [9999], "active": 9999}),
|
||||
json!({"ids": [vis[1].0], "active": 9999}),
|
||||
json!({"ids": [vis[1].0, 9999]}),
|
||||
json!({"ids": [9999], "mode": "add"}),
|
||||
json!({"ids": [9999], "mode": "toggle"}),
|
||||
json!({"ids": [9999], "mode": "range"}),
|
||||
] {
|
||||
let e = s.execute("library.select", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("no such photo 9999"), "{p}: {e}");
|
||||
assert_eq!(s.selection, before, "{p}");
|
||||
}
|
||||
assert_eq!(s.active(), Some(vis[0]));
|
||||
assert!(s.execute("develop.get", &json!({})).is_ok());
|
||||
// queries about an explicit id check it too
|
||||
for c in ["develop.get", "photo.inspect", "photo.allMetadata", "history.list"] {
|
||||
let e = s.execute(c, &json!({"id": 9999})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such photo 9999"), "{c}: {e}");
|
||||
assert!(s.execute(c, &json!({"id": vis[1].0})).is_ok(), "{c}");
|
||||
}
|
||||
// an empty selection is still allowed
|
||||
assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0);
|
||||
}
|
||||
|
||||
/// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps
|
||||
/// working (the stale keys are dropped), while a typo the caller makes is still refused (#181).
|
||||
#[test]
|
||||
fn saved_presets_with_unknown_keys_still_export() {
|
||||
use crate::export::{ExportOptions, ExportPreset};
|
||||
let mut s = demo();
|
||||
s.export_presets.push(ExportPreset {
|
||||
name: "Old".into(),
|
||||
params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}),
|
||||
});
|
||||
let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap();
|
||||
let o = ExportOptions::from_params(&p).unwrap();
|
||||
assert_eq!(o.format, crate::export::ExportFormat::Png);
|
||||
assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string()));
|
||||
assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err());
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Exploring the library by folder: `library.folders` lists where the imported photos live and
|
||||
//! `library.filter {libraryFolder}` shows one folder's photos (see `lightcraft_catalog::folders`);
|
||||
//! `library.removeFolder` takes a folder's photos out of the library.
|
||||
//!
|
||||
//! * Given photos imported from two folders, an agent can list them with counts, then choose one
|
||||
//! and see exactly its photos; the filter shows as a chip that clears just that choice.
|
||||
//! * A path that is not a library folder shows nothing rather than everything.
|
||||
//! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and
|
||||
//! leaves every file where it is.
|
||||
//! * Renaming or moving a folder on disk keeps the chosen folder chosen.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{LibrarySource, Session, filter_chips};
|
||||
|
||||
fn source_folder(s: &mut Session, path: &str) -> serde_json::Value {
|
||||
s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap()
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, path: &str) {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
|
||||
/// 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-folders-{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 session() -> Session {
|
||||
let mut s = Session::new();
|
||||
for path in ["/pics/trip/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"] {
|
||||
add(&mut s, path);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_command_lists_where_the_photos_were_imported_from() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.folders", &json!({})).unwrap();
|
||||
assert_eq!((r[0]["name"].as_str(), r[0]["volume"].as_bool(), r[0]["count"].as_u64()), (Some("/"), Some(true), Some(3)));
|
||||
let pics = &r[0]["children"][0];
|
||||
assert_eq!(pics["name"], "pics");
|
||||
let kids: Vec<(&str, u64)> =
|
||||
pics["children"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["count"].as_u64().unwrap())).collect();
|
||||
assert_eq!(kids, [("home", 1), ("trip", 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_shows_its_photos_and_a_chip_that_clears_it() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
let r = s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
let chips = filter_chips(&s.filter, &s.catalog);
|
||||
assert_eq!(chips.len(), 1);
|
||||
assert_eq!(chips[0].label, "Folder: pics/trip", "two names, so two folders called trip are told apart");
|
||||
s.execute("library.filter", &chips[0].clear).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "clearing the chip shows everything again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_is_not_a_library_folder_shows_nothing() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.filter", &json!({"libraryFolder": "/elsewhere"})).unwrap();
|
||||
assert_eq!(r["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_back() {
|
||||
let mut s = session();
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/pics//trip/"})).unwrap();
|
||||
assert_eq!(r["removed"], 2);
|
||||
assert_eq!(s.visible().len(), 1, "only /pics/home is left");
|
||||
let tree = s.execute("library.folders", &json!({})).unwrap();
|
||||
assert_eq!(tree[0]["count"], 1);
|
||||
s.source = LibrarySource::RecentlyDeleted;
|
||||
assert_eq!(s.visible().len(), 2, "they wait in Recently Deleted");
|
||||
s.source = LibrarySource::All;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/elsewhere"})).is_err());
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/"})).is_err(), "choose a folder, not everything");
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": " "})).is_err());
|
||||
assert!(s.execute("library.removeFolder", &json!({})).is_err());
|
||||
assert_eq!(s.visible().len(), 3, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_leaves_browsed_and_already_deleted_photos_alone() {
|
||||
let mut s = session();
|
||||
for (path, local, deleted) in [("/pics/trip/browsed.jpg", true, false), ("/pics/trip/gone.jpg", false, true)] {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
(p.local, p.deleted) = (local, deleted);
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(r["removed"], 2, "only the two library photos of the folder");
|
||||
let state: Vec<(bool, bool)> = s.catalog.photos().filter(|p| p.local || p.deleted).map(|p| (p.local, p.deleted)).collect();
|
||||
assert!(state.contains(&(true, false)) && state.contains(&(false, true)), "browsed stays browsed, the old deletion stays: {state:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_folder_that_is_chosen_clears_the_choice_but_a_parent_stays() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder, None, "no empty grid under a chip for a folder that is gone");
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics"})).unwrap();
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/pics"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_folder_removes_nothing() {
|
||||
let mut s = session();
|
||||
for path in [".", "./", "a/..", "../..", "x/../.."] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?}");
|
||||
}
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_disk_goes_only_when_asked_for_by_name_and_never_the_startup_disk() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Volumes/nas/2.jpg", "/Users/me/3.jpg", r"C:\x\4.jpg", r"\\srv\share\5.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
for path in ["/Volumes/nas", "C:", "C:\\", r"\\?\C:\", "C:\\..", r"\\srv\share"] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path})).is_err(), "{path:?} is a whole disk");
|
||||
}
|
||||
for path in ["/", "//"] {
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": path, "disk": true})).is_err(), "{path:?}: never the startup disk");
|
||||
}
|
||||
assert_eq!(s.visible().len(), 5, "nothing changed");
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/Volumes/nas", "disk": true})).unwrap();
|
||||
assert_eq!(r["removed"], 2, "everything on the nas, nothing elsewhere");
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_spans_several_disks_is_removed_only_when_asked_for_by_name() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Volumes/tokyo/2.jpg", "/Volumes/3.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take two disks' photos at once");
|
||||
assert_eq!(s.visible().len(), 3);
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/Volumes", "disk": true})).unwrap();
|
||||
assert_eq!(r["removed"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folder_filter_takes_only_a_text_path() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": 5})).is_err());
|
||||
assert_eq!(s.filter.library_folder, None, "refused, nothing changed");
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "a blank path is no choice");
|
||||
s.execute("library.filter", &json!({"libraryFolder": "."})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0, "a path that names no folder shows nothing, not everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, renamed) = (dir.path("trip"), dir.path("holiday"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{trip}/day1/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(renamed.as_str()), "the choice follows the folder");
|
||||
assert_eq!(s.visible().len(), 2, "and still shows its photos");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(trip.as_str()));
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_folder_keeps_a_chosen_subfolder_chosen() {
|
||||
let dir = Scratch::new("move");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
std::fs::create_dir_all(dir.0.join("archive")).unwrap();
|
||||
let (day1, moved) = (dir.path("trip/day1"), dir.path("archive/trip/day1"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap();
|
||||
s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(moved.as_str()), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some(day1.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn following_a_folder_reads_both_spellings_the_same_way() {
|
||||
// the choice was typed with a `..` in it; the folder that moved was not
|
||||
let mut s = Session::new();
|
||||
s.filter.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/a/c/sub"), "a subfolder stays a subfolder, never widens to its parent");
|
||||
s.filter.library_folder = Some("/elsewhere/b".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_folder_replaces_the_source_the_way_an_album_does() {
|
||||
let mut s = session();
|
||||
s.execute("library.source", &json!({"kind": "picks"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0, "no picks");
|
||||
let r = source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip")));
|
||||
assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip");
|
||||
let state = s.execute("library.state", &json!({})).unwrap();
|
||||
assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder");
|
||||
// the filter bar narrows it further, as it does an album
|
||||
s.execute("library.filter", &json!({"rating": 1})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0);
|
||||
s.execute("library.clearFilter", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 3, "back to everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folder_source_takes_a_library_folder_path_only() {
|
||||
let mut s = session();
|
||||
for params in [
|
||||
json!({"kind": "libraryFolder"}),
|
||||
json!({"kind": "libraryFolder", "path": 5}),
|
||||
json!({"kind": "libraryFolder", "path": " "}),
|
||||
json!({"kind": "libraryFolder", "path": "."}),
|
||||
] {
|
||||
assert!(s.execute("library.source", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
assert_eq!(s.source, LibrarySource::All, "refused, nothing changed");
|
||||
assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/elsewhere"})).is_err(), "no photo was imported from it");
|
||||
assert_eq!(s.source, LibrarySource::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_folder_being_shown_returns_to_all_photos() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
// removing another folder leaves the source alone
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/home");
|
||||
s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();
|
||||
assert_eq!(s.source, LibrarySource::LibraryFolder);
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() {
|
||||
let dir = Scratch::new("source-rename");
|
||||
std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap();
|
||||
let (trip, day1, renamed) = (dir.path("trip"), dir.path("trip/day1"), dir.path("holiday"));
|
||||
let mut s = Session::new();
|
||||
add(&mut s, &format!("{trip}/a.jpg"));
|
||||
add(&mut s, &format!("{day1}/b.jpg"));
|
||||
source_folder(&mut s, &day1);
|
||||
s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.library_folder.as_deref(), Some(day1.as_str()));
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shown_folder_follows_reading_both_spellings_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.library_folder = Some("/a/x/../b/sub".into());
|
||||
crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");
|
||||
assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_one_folder_to_another_changes_what_is_shown() {
|
||||
let mut s = session();
|
||||
assert_eq!(source_folder(&mut s, "/pics/trip")["count"], 2);
|
||||
assert_eq!(s.source_total(), Some(2));
|
||||
assert_eq!(source_folder(&mut s, "/pics/home")["count"], 1, "same source, another folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
assert_eq!(s.source_total(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smart_album_made_from_a_folder_view_keeps_the_folder() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap();
|
||||
assert_eq!(r["count"], 2, "the album matches the folder's photos, not the whole library: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_source_and_a_folder_filter_never_disagree() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).unwrap();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.filter.library_folder, None, "choosing a folder drops a folder filter left over");
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_err(), "one folder at a time");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
s.execute("library.filter", &json!({"libraryFolder": null, "rating": 0})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_ok(), "fine while no folder is the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_source_with_no_folder_falls_back_to_all_photos() {
|
||||
let mut s = session();
|
||||
s.source = LibrarySource::LibraryFolder;
|
||||
s.library_folder = None;
|
||||
assert_eq!(s.visible().len(), 3, "everything, not an empty grid under no name");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
assert_eq!(s.source_total(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_that_shows_exactly_its_own_photos_can_be_the_source() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg", "/Volumes/3.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
for path in ["/", "//", "/elsewhere", "/Volumes"] {
|
||||
assert!(s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).is_err(), "{path:?}");
|
||||
}
|
||||
assert_eq!(s.source, LibrarySource::All, "refused, nothing changed");
|
||||
assert_eq!(source_folder(&mut s, "/Volumes/nas")["count"], 1, "a disk is a fine source");
|
||||
assert_eq!(source_folder(&mut s, "/Users/me")["count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_folder_above_a_disk_needs_asking_by_name_even_with_one_disk() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/Volumes/nas/a/1.jpg", "/Users/me/2.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert!(s.execute("library.removeFolder", &json!({"path": "/Volumes"})).is_err(), "it would take the whole nas");
|
||||
assert_eq!(s.visible().len(), 2);
|
||||
assert_eq!(s.execute("library.removeFolder", &json!({"path": "/Volumes/nas/a"})).unwrap()["removed"], 1, "a folder on the disk is just a folder");
|
||||
assert_eq!(s.visible().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_with_spaces_at_its_edge_is_never_mistaken_for_its_neighbour() {
|
||||
let mut s = Session::new();
|
||||
for p in ["/p/shoot /a.jpg", "/p/shoot/b.jpg", "/p/other/c.jpg"] {
|
||||
add(&mut s, p);
|
||||
}
|
||||
assert_eq!(source_folder(&mut s, "/p/shoot ")["count"], 1);
|
||||
let shown: Vec<String> = s.visible().to_vec().iter().filter_map(|id| s.catalog.photo(*id)).map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(s.library_folder.as_deref(), Some("/p/shoot "), "kept as the tree gave it");
|
||||
assert_eq!(shown.len(), 1);
|
||||
let before: Vec<_> = s.catalog.photos().filter(|p| p.deleted).map(|p| p.id).collect();
|
||||
assert!(before.is_empty());
|
||||
let r = s.execute("library.removeFolder", &json!({"path": "/p/shoot "})).unwrap();
|
||||
assert_eq!(r["removed"], 1);
|
||||
let gone: Vec<String> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter(|p| p.deleted)
|
||||
.map(|p| match &p.source {
|
||||
Source::File { path } => path.clone(),
|
||||
Source::Demo { .. } => String::new(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(gone, ["/p/shoot /a.jpg"], "the folder named, not its neighbour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_smart_album_made_from_a_folder_view_keeps_the_folder() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
let r = s.execute("album.createSmart", &json!({"name": "Trip"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
// the rules dialog saves what it shows (no folder field) with `replace`
|
||||
let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"ruleSet": {"rules": []}}})).unwrap();
|
||||
assert_eq!(r["count"], 2, "the folder is not something the dialog can show, so it is kept: {r}");
|
||||
let r = s.execute("album.setRules", &json!({"id": id, "replace": true, "rules": {"libraryFolder": null}})).unwrap();
|
||||
assert_eq!(r["count"], 3, "and can be dropped on purpose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shown_folder_that_loses_its_last_photo_gives_way_to_all_photos() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/home");
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
assert_eq!(s.visible().len(), 2, "back to everything that is left");
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None));
|
||||
// a folder whose photos are only hidden by the filter bar stays
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
s.execute("library.filter", &json!({"rating": 5})).unwrap();
|
||||
assert_eq!(s.visible().len(), 0);
|
||||
assert_eq!(s.source, LibrarySource::LibraryFolder, "the filters hide them, the folder is still there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_names_a_folder_only_while_it_is_shown() {
|
||||
let mut s = session();
|
||||
source_folder(&mut s, "/pics/trip");
|
||||
assert_eq!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"], "/pics/trip");
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
assert!(s.execute("library.state", &json!({})).unwrap()["libraryFolder"].is_null(), "not a stale leftover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_folder_filter_is_no_filter() {
|
||||
let mut s = session();
|
||||
s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();
|
||||
assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state");
|
||||
}
|
||||
@@ -545,12 +545,17 @@ fn cube_luts_become_profiles() {
|
||||
);
|
||||
s.execute("develop.profile", &json!({"id": id, "amount": 0})).unwrap();
|
||||
assert_eq!(s.render_now(photo, 48, 32).unwrap().image.data, base.data, "Amount 0 = the photo as it was");
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
s.execute("profile.favorite", &json!({"id": id})).unwrap();
|
||||
assert!(s.profile_favorites.contains(&id));
|
||||
// the library keeps it
|
||||
drop(s);
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.lut_profiles.iter().any(|p| p.id == id));
|
||||
assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen");
|
||||
assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks")));
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open");
|
||||
s.execute("profile.deleteImported", &json!({"id": id})).unwrap();
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_none());
|
||||
@@ -858,3 +863,147 @@ fn missing_photos_skip_local_browse_records() {
|
||||
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
|
||||
}
|
||||
|
||||
/// Import a file that is in Recently Deleted (issue #298): by default it is skipped but the
|
||||
/// report says it is in the trash; `onDeleted: restore` brings it back with its edits;
|
||||
/// `onDeleted: fresh` replaces the trashed record with a new one.
|
||||
fn trashed_photo(tag: &str) -> (Session, std::path::PathBuf, u64) {
|
||||
let src = temp_dir(tag);
|
||||
write_png(&src.join("a.png"), 1);
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let id = r["imported"][0].as_u64().unwrap();
|
||||
s.execute("photo.rate", &json!({"ids": [id], "rating": 4})).unwrap();
|
||||
s.execute("photo.delete", &json!({"ids": [id]})).unwrap();
|
||||
(s, src, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_in_recently_deleted_is_reported_as_such() {
|
||||
let (mut s, src, id) = trashed_photo("trash-report");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 0, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["existing"], id, "{r}");
|
||||
assert_eq!(r["duplicates"][0]["existingDeleted"], true, "{r}");
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().deleted, "default: left in the trash");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_can_restore_a_duplicate_from_recently_deleted() {
|
||||
let (mut s, src, id) = trashed_photo("trash-restore");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore"})).unwrap();
|
||||
assert_eq!(r["restored"], json!([id]), "{r}");
|
||||
assert_eq!(ids(&r, "duplicates"), 0, "{r}");
|
||||
let p = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap();
|
||||
assert!(!p.deleted && p.rating == 4, "restored with its edits");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_can_replace_a_duplicate_from_recently_deleted() {
|
||||
let (mut s, src, id) = trashed_photo("trash-fresh");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
let new = r["imported"][0].as_u64().unwrap();
|
||||
assert_ne!(new, id);
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none(), "the trashed record is gone");
|
||||
let p = s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap();
|
||||
assert!(!p.deleted && p.rating == 0, "a new photo without the old edits");
|
||||
assert_eq!(s.catalog.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_on_deleted_is_an_error() {
|
||||
let mut s = Session::new().with_fs();
|
||||
let e = s.execute("library.import", &json!({"paths": [], "onDeleted": "bogus"})).unwrap_err().to_string();
|
||||
assert!(e.contains("onDeleted"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_recently_deleted_removes_only_trashed_photos() {
|
||||
let (mut s, src, id) = trashed_photo("trash-empty");
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
let keep = r["imported"][0].as_u64().unwrap();
|
||||
let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 1, "{r}");
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none());
|
||||
assert!(s.catalog.photo(lightcraft_catalog::PhotoId(keep)).is_some());
|
||||
// nothing to empty is not an error
|
||||
assert_eq!(s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap()["deleted"], 0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Two trashed photos that share an album and a two-photo stack: emptying (or permanently
|
||||
/// deleting) them together must not fail or leave dangling references.
|
||||
fn two_trashed_in_album_and_stack(tag: &str) -> (Session, std::path::PathBuf, [u64; 2], u64) {
|
||||
let src = temp_dir(tag);
|
||||
write_png(&src.join("a.png"), 1);
|
||||
write_png(&src.join("b.png"), 2);
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
let ids = [r["imported"][0].as_u64().unwrap(), r["imported"][1].as_u64().unwrap()];
|
||||
let al = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("album.addPhotos", &json!({"id": al, "ids": ids})).unwrap();
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
s.execute("stack.group", &json!({"ids": ids})).unwrap();
|
||||
s.execute("photo.delete", &json!({"ids": ids})).unwrap();
|
||||
(s, src, ids, al)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-empty-refs");
|
||||
let r = s.execute("library.emptyRecentlyDeleted", &json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 2, "{r}");
|
||||
assert_eq!(s.catalog.len(), 0);
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty(), "album still lists a removed photo");
|
||||
assert!(ids.iter().all(|i| s.catalog.stack_of(lightcraft_catalog::PhotoId(*i)).is_none()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.len(), 2, "one undo step brings both back");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.len(), 2);
|
||||
assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_some());
|
||||
assert!(s.catalog.photos().all(|p| p.deleted), "back in Recently Deleted");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_several_photos_permanently_leaves_nothing_dangling() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs");
|
||||
s.execute("library.select", &json!({"ids": ids})).unwrap();
|
||||
s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap();
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_import_of_two_trashed_photos_in_one_album_and_stack_works() {
|
||||
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-fresh-refs");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
|
||||
assert_eq!(ids_len(&r), 2, "{r}");
|
||||
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
|
||||
assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none()));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn ids_len(r: &Value) -> usize {
|
||||
ids(r, "imported")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_import_applies_the_album_to_restored_photos() {
|
||||
let (mut s, src, ids, _) = trashed_photo_ids("trash-restore-album");
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "restore", "albumName": "Back"})).unwrap();
|
||||
assert_eq!(r["restored"], json!(ids), "{r}");
|
||||
let album = r["album"].as_u64().expect("album created for the restored photos");
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn trashed_photo_ids(tag: &str) -> (Session, std::path::PathBuf, [u64; 1], u64) {
|
||||
let (s, src, id) = trashed_photo(tag);
|
||||
(s, src, [id], 0)
|
||||
}
|
||||
@@ -212,6 +212,39 @@ fn profile_favorites_and_recent_survive_reopen() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shown_library_folder_survives_reopen() {
|
||||
let dir = temp_dir("libfolder");
|
||||
let mut s = open(&dir, true);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = lightcraft_catalog::Photo::new(
|
||||
id,
|
||||
lightcraft_catalog::Source::File { path: "/pics/trip/a.jpg".into() },
|
||||
"a.jpg",
|
||||
"JPEG",
|
||||
60,
|
||||
40,
|
||||
"2026-01-01T10:00:00",
|
||||
);
|
||||
s.commit("Add", lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/pics/trip"})).unwrap();
|
||||
s.save_view();
|
||||
drop(s);
|
||||
let s = open(&dir, false);
|
||||
assert_eq!((s.source, s.library_folder.as_deref()), (crate::LibrarySource::LibraryFolder, Some("/pics/trip")));
|
||||
drop(s);
|
||||
// a folder that holds none of the library's photos any more opens on everything too
|
||||
std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}, "library_folder": "/no/such/folder"}"#).unwrap();
|
||||
let s = open(&dir, false);
|
||||
assert_eq!((s.source, s.library_folder.clone()), (crate::LibrarySource::All, None));
|
||||
drop(s);
|
||||
// a view file that names the source but no folder opens on everything, never an empty grid
|
||||
std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}}"#).unwrap();
|
||||
let s = open(&dir, false);
|
||||
assert_eq!((s.source, s.library_folder), (crate::LibrarySource::All, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates.
|
||||
#[test]
|
||||
fn smart_album_rule_sets() {
|
||||
|
||||
@@ -19,6 +19,9 @@ pub enum LibrarySource {
|
||||
Folder,
|
||||
/// Photos whose original file can't be found (`library.missing`).
|
||||
Missing,
|
||||
/// A folder the library's photos were imported from ([`crate::Session::library_folder`]),
|
||||
/// and the folders inside it (`library.folders`).
|
||||
LibraryFolder,
|
||||
}
|
||||
|
||||
/// The folder a [`LibrarySource::Folder`] view shows.
|
||||
@@ -49,7 +52,7 @@ impl LibrarySource {
|
||||
LibrarySource::RecentlyDeleted => f.deleted = true,
|
||||
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
|
||||
// the folder itself is filled in by the session (it holds the path)
|
||||
LibrarySource::Folder | LibrarySource::Missing => {}
|
||||
LibrarySource::Folder | LibrarySource::Missing | LibrarySource::LibraryFolder => {}
|
||||
}
|
||||
f
|
||||
}
|
||||
@@ -62,6 +65,7 @@ impl LibrarySource {
|
||||
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
|
||||
LibrarySource::Picks => "Picks".into(),
|
||||
LibrarySource::Folder => "Folder".into(),
|
||||
LibrarySource::LibraryFolder => "Folder".into(),
|
||||
LibrarySource::Missing => "Missing Photos".into(),
|
||||
}
|
||||
}
|
||||
@@ -74,7 +78,27 @@ pub struct Selection {
|
||||
pub active: Option<PhotoId>,
|
||||
}
|
||||
|
||||
/// How a photo relates to the selection: what every view draws (grid cell, filmstrip cell…).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SelectionState {
|
||||
NotSelected,
|
||||
/// Selected, but not the photo the loupe and panels act on.
|
||||
Selected,
|
||||
/// The most-selected photo.
|
||||
Active,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// What views draw for `id`: active, selected or neither (one answer for grid, filmstrip, …).
|
||||
pub fn state_of(&self, id: PhotoId) -> SelectionState {
|
||||
if self.active == Some(id) {
|
||||
SelectionState::Active
|
||||
} else if self.contains(id) {
|
||||
SelectionState::Selected
|
||||
} else {
|
||||
SelectionState::NotSelected
|
||||
}
|
||||
}
|
||||
pub fn single(id: PhotoId) -> Selection {
|
||||
Selection { ids: vec![id], active: Some(id) }
|
||||
}
|
||||
@@ -173,6 +197,11 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
|
||||
if let Some(d) = &f.date {
|
||||
add(format!("Date: {}", date_label(d)), json!({"date": Null}));
|
||||
}
|
||||
if let Some(d) = f.library_folder.as_deref().filter(|d| !d.trim().is_empty()) {
|
||||
// the last two names, so two folders called "Pictures" are told apart
|
||||
let name = lightcraft_catalog::folders::folder_label(d);
|
||||
add(format!("Folder: {name}"), json!({"libraryFolder": Null}));
|
||||
}
|
||||
if let Some(d) = &f.imported {
|
||||
add(format!("Imported: {}", date_label(d)), json!({"imported": Null}));
|
||||
}
|
||||
@@ -203,3 +232,18 @@ fn date_label(d: &str) -> String {
|
||||
_ => d.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_state_tests {
|
||||
use super::*;
|
||||
use lightcraft_catalog::PhotoId;
|
||||
|
||||
#[test]
|
||||
fn select_all_marks_the_others_selected_and_one_active() {
|
||||
let ids: Vec<PhotoId> = (1..=3).map(PhotoId).collect();
|
||||
let s = Selection { ids: ids.clone(), active: Some(ids[0]) };
|
||||
assert_eq!(s.state_of(ids[0]), SelectionState::Active);
|
||||
assert_eq!(s.state_of(ids[1]), SelectionState::Selected);
|
||||
assert_eq!(s.state_of(PhotoId(9)), SelectionState::NotSelected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# lightcraft-fetch
|
||||
|
||||
Native model downloads in pure Rust (L0, Apache-2.0): HTTP/1.1 over std sockets and rustls with the RustCrypto provider, without ring, aws-lc, C or assembly build dependencies. The wasm32 crate is empty.
|
||||
|
||||
Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host.
|
||||
|
||||
Tests use local synthetic HTTP servers for redirects, resume, truncation, cancellation, stalls, hashes, size limits and hostile specifications; they make no internet requests.
|
||||
@@ -60,7 +60,10 @@ pub struct Url {
|
||||
|
||||
impl Url {
|
||||
pub fn parse(s: &str) -> Result<Url, HttpError> {
|
||||
let bad = |why: &str| HttpError::BadUrl(format!("{s}: {why}"));
|
||||
let bad = |why: &str| HttpError::BadUrl(why.to_string());
|
||||
if s.len() > 16 * 1024 {
|
||||
return Err(bad("URL exceeds 16 KiB"));
|
||||
}
|
||||
let s = s.trim();
|
||||
if s.chars().any(|c| c.is_control() || c == ' ') {
|
||||
return Err(bad("contains spaces or control characters"));
|
||||
@@ -509,6 +512,12 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_and_control_character_urls() {
|
||||
assert!(Url::parse(&format!("https://example.com/{}", "x".repeat(16 * 1024))).is_err());
|
||||
assert!(Url::parse("https://example.com/x?secret=abc\r\nInjected: 1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_urls() {
|
||||
let u = Url::parse("https://cdn.example.com/models/sam3/model.safetensors?sig=1#x").unwrap();
|
||||
|
||||
+93
-20
@@ -33,12 +33,12 @@ use http::{HttpError, Limits, Url};
|
||||
|
||||
/// One file of the model.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FileSpec {
|
||||
pub name: &'static str,
|
||||
pub struct FileSpec<'a> {
|
||||
pub name: &'a str,
|
||||
/// Exact size in bytes, when pinned (then the download resumes and is checked against it).
|
||||
pub size: Option<u64>,
|
||||
/// SHA-256 (lower-case hex), when pinned.
|
||||
pub sha256: Option<&'static str>,
|
||||
pub sha256: Option<&'a str>,
|
||||
/// Largest size accepted when `size` is not pinned.
|
||||
pub max: u64,
|
||||
}
|
||||
@@ -79,11 +79,14 @@ pub struct Options {
|
||||
pub stall_timeout: Duration,
|
||||
/// Tries per mirror and file (each resumes where the last one stopped).
|
||||
pub attempts: u32,
|
||||
/// The environment variable holding a bearer token, sent to each configured mirror's own host (only over https,
|
||||
/// never to a host a redirect leads to), so list only mirrors that may see it. `None`: nothing is ever sent, so one model's token cannot leak to another's server.
|
||||
pub token_env: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3 }
|
||||
Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3, token_env: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +106,8 @@ pub struct Progress {
|
||||
pub enum DownloadError {
|
||||
/// No mirror is configured.
|
||||
NoMirrors,
|
||||
/// Invalid filename, size, hash or aggregate bound supplied by the caller.
|
||||
Invalid(String),
|
||||
Cancelled,
|
||||
/// A local file problem (folder not writable, disk full…).
|
||||
Disk(String),
|
||||
@@ -116,6 +121,7 @@ pub enum DownloadError {
|
||||
impl std::fmt::Display for DownloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DownloadError::Invalid(e) => write!(f, "invalid download: {e}"),
|
||||
DownloadError::NoMirrors => write!(f, "no download location is configured for this model in this build"),
|
||||
DownloadError::Cancelled => write!(f, "the download was cancelled"),
|
||||
DownloadError::Disk(e) => write!(f, "{e}"),
|
||||
@@ -132,16 +138,45 @@ enum Fail {
|
||||
Disk(String),
|
||||
}
|
||||
|
||||
/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Files already there
|
||||
/// and verified are kept. `progress` is called as bytes arrive (often: throttle in it).
|
||||
/// Fetch a single file from its exact URL, preserving any query string. The local filename
|
||||
/// remains `file.name`; it never comes from redirects or response headers.
|
||||
pub fn download_url(
|
||||
file: &FileSpec<'_>,
|
||||
url: &str,
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
) -> Result<(), DownloadError> {
|
||||
download_source(std::slice::from_ref(file), &[url.to_string()], dir, opts, cancel, progress, true)
|
||||
}
|
||||
|
||||
/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Verified files
|
||||
/// are kept. `progress` is called as bytes arrive (often: throttle in it).
|
||||
pub fn download(
|
||||
files: &[FileSpec],
|
||||
files: &[FileSpec<'_>],
|
||||
mirrors: &[String],
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
) -> Result<(), DownloadError> {
|
||||
download_source(files, mirrors, dir, opts, cancel, progress, false)
|
||||
}
|
||||
|
||||
fn download_source(
|
||||
files: &[FileSpec<'_>],
|
||||
mirrors: &[String],
|
||||
dir: &Path,
|
||||
opts: &Options,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(&Progress),
|
||||
exact_urls: bool,
|
||||
) -> Result<(), DownloadError> {
|
||||
let total = validate_files(files)?;
|
||||
if mirrors.len() > MAX_MIRRORS {
|
||||
return Err(DownloadError::Invalid("too many mirrors".into()));
|
||||
}
|
||||
std::fs::create_dir_all(dir).map_err(|e| DownloadError::Disk(format!("can't create {}: {e}", dir.display())))?;
|
||||
// what's left to fetch
|
||||
let mut todo = Vec::new();
|
||||
@@ -156,14 +191,13 @@ pub fn download(
|
||||
if mirrors.is_empty() {
|
||||
return Err(DownloadError::NoMirrors);
|
||||
}
|
||||
let total: u64 = files.iter().map(|f| f.size.unwrap_or(0)).sum();
|
||||
let mut before: u64 = files.iter().filter(|f| !todo.contains(f)).filter_map(|f| f.size).sum();
|
||||
for f in todo {
|
||||
let mut errors = Vec::new();
|
||||
let mut ok = false;
|
||||
'mirrors: for m in mirrors {
|
||||
let url = format!("{m}/{}", f.name);
|
||||
for _ in 0..opts.attempts.max(1) {
|
||||
let url = if exact_urls { m.clone() } else { format!("{m}/{}", f.name) };
|
||||
for _ in 0..opts.attempts.clamp(1, 16) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DownloadError::Cancelled);
|
||||
}
|
||||
@@ -184,11 +218,11 @@ pub fn download(
|
||||
Err(Fail::Cancelled) => return Err(DownloadError::Cancelled),
|
||||
Err(Fail::Disk(e)) => return Err(DownloadError::Disk(e)),
|
||||
Err(Fail::Retry(e)) => {
|
||||
log::warn!("download of {url}: {e} (retrying)");
|
||||
log::warn!("download of {}: {e} (retrying)", short(&url));
|
||||
errors.push(format!("{}: {e}", short(m)));
|
||||
}
|
||||
Err(Fail::NextMirror(e)) => {
|
||||
log::warn!("download of {url}: {e}");
|
||||
log::warn!("download of {}: {e}", short(&url));
|
||||
errors.push(format!("{}: {e}", short(m)));
|
||||
continue 'mirrors;
|
||||
}
|
||||
@@ -203,13 +237,56 @@ pub fn download(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate before touching disk, including names supplied by a user model catalog.
|
||||
fn validate_files(files: &[FileSpec<'_>]) -> Result<u64, DownloadError> {
|
||||
if files.len() > 256 {
|
||||
return Err(DownloadError::Invalid("too many files".into()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut total = 0u64;
|
||||
for f in files {
|
||||
let stem = f.name.split('.').next().unwrap_or_default().to_ascii_uppercase();
|
||||
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| stem
|
||||
.strip_prefix("COM")
|
||||
.or_else(|| stem.strip_prefix("LPT"))
|
||||
.is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
if reserved
|
||||
|| f.name.ends_with(['.', ' '])
|
||||
|| f.name.ends_with(".part")
|
||||
|| f.name.is_empty()
|
||||
|| f.name.len() > 255
|
||||
|| f.name.starts_with('.')
|
||||
|| f.name.contains(['/', '\\', ':'])
|
||||
|| f.name.chars().any(char::is_control)
|
||||
|| !seen.insert(f.name)
|
||||
{
|
||||
return Err(DownloadError::Invalid("a filename must be a unique, ordinary filename".into()));
|
||||
}
|
||||
if f.max == 0
|
||||
|| f.size.is_some_and(|n| n == 0 || n > f.max)
|
||||
|| f.sha256.is_some_and(|h| h.len() != 64 || !h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
|
||||
{
|
||||
return Err(DownloadError::Invalid("invalid file size or SHA-256".into()));
|
||||
}
|
||||
total = total.checked_add(f.size.unwrap_or(0)).ok_or_else(|| DownloadError::Invalid("total size overflow".into()))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// The bearer token to send to `url`: only a non-empty one, only over https, and only to the host the download began
|
||||
/// at (`origin`), never to the host a redirect leads to.
|
||||
fn bearer<'a>(token: Option<&'a str>, url: &Url, origin: &str) -> Option<&'a str> {
|
||||
token.map(str::trim).filter(|t| !t.is_empty() && url.tls && url.host == origin)
|
||||
}
|
||||
|
||||
/// A mirror for messages (no query string: it may hold a token).
|
||||
fn short(m: &str) -> String {
|
||||
Url::parse(m).map(|u| u.display()).unwrap_or_else(|_| "mirror".into())
|
||||
}
|
||||
|
||||
/// Whether `path` is already the right file (exact size and hash when pinned).
|
||||
fn verified(f: &FileSpec, path: &Path) -> Result<bool, DownloadError> {
|
||||
fn verified(f: &FileSpec<'_>, path: &Path) -> Result<bool, DownloadError> {
|
||||
let Ok(meta) = std::fs::metadata(path) else { return Ok(false) };
|
||||
if !meta.is_file() || meta.len() == 0 {
|
||||
return Ok(false);
|
||||
@@ -243,7 +320,7 @@ fn part_path(dir: &Path, name: &str) -> PathBuf {
|
||||
|
||||
/// Fetch one file from one mirror into `<name>.part` (resuming it when its size is pinned),
|
||||
/// check it and move it into place.
|
||||
fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> {
|
||||
fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> {
|
||||
let part = part_path(dir, f.name);
|
||||
let limit = f.size.unwrap_or(f.max);
|
||||
let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display()));
|
||||
@@ -268,12 +345,8 @@ fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &Atom
|
||||
headers.push(("Range", format!("bytes={have}-")));
|
||||
}
|
||||
// a token for the configured host only (never sent on to a redirect's host)
|
||||
if let Ok(t) = std::env::var("LIGHTCRAFT_SAM3_TOKEN")
|
||||
&& !t.trim().is_empty()
|
||||
&& url.tls
|
||||
&& url.host == origin
|
||||
{
|
||||
headers.push(("Authorization", format!("Bearer {}", t.trim())));
|
||||
if let Some(t) = bearer(opts.token_env.and_then(|name| std::env::var(name).ok()).as_deref(), &url, &origin) {
|
||||
headers.push(("Authorization", format!("Bearer {t}")));
|
||||
}
|
||||
let r = http::get(&url, &headers, &limits).map_err(http_fail)?;
|
||||
match r.status {
|
||||
|
||||
@@ -22,7 +22,7 @@ fn leak(s: String) -> &'static str {
|
||||
Box::leak(s.into_boxed_str())
|
||||
}
|
||||
|
||||
fn pinned() -> FileSpec {
|
||||
fn pinned() -> FileSpec<'static> {
|
||||
let p = payload();
|
||||
FileSpec { name: "model.bin", size: Some(p.len() as u64), sha256: Some(leak(sha_hex(&p))), max: p.len() as u64 }
|
||||
}
|
||||
@@ -137,10 +137,10 @@ fn tmp(name: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
fn quick() -> Options {
|
||||
Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2 }
|
||||
Options { connect_timeout: Duration::from_secs(2), stall_timeout: Duration::from_millis(600), attempts: 2, token_env: None }
|
||||
}
|
||||
|
||||
fn run(files: &[FileSpec], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec<Progress>) {
|
||||
fn run(files: &[FileSpec<'_>], mirrors: &[String], dir: &Path, cancel: &AtomicBool) -> (Result<(), DownloadError>, Vec<Progress>) {
|
||||
let mut seen = Vec::new();
|
||||
let r = download(files, mirrors, dir, &quick(), cancel, &mut |p| seen.push(p.clone()));
|
||||
(r, seen)
|
||||
@@ -314,3 +314,75 @@ fn content_ranges() {
|
||||
assert_eq!(parse_content_range("bytes 9-1/300"), None);
|
||||
assert_eq!(parse_content_range("items 1-2/3"), None);
|
||||
}
|
||||
|
||||
/// A bearer token goes only to https, only to the host the download began at, and only when there is one.
|
||||
#[test]
|
||||
fn a_token_is_sent_only_over_https_to_the_first_host() {
|
||||
let u = |s: &str| Url::parse(s).unwrap();
|
||||
let first = u("https://huggingface.co/x/model.bin");
|
||||
assert_eq!(bearer(Some(" hf_abc \n"), &first, "huggingface.co"), Some("hf_abc"));
|
||||
// the storage host a redirect leads to, plain http, no token, an empty one
|
||||
assert_eq!(bearer(Some("hf_abc"), &u("https://cdn-lfs.example/x"), "huggingface.co"), None);
|
||||
assert_eq!(bearer(Some("hf_abc"), &u("http://huggingface.co/x"), "huggingface.co"), None);
|
||||
assert_eq!(bearer(None, &first, "huggingface.co"), None);
|
||||
assert_eq!(bearer(Some(" "), &first, "huggingface.co"), None);
|
||||
// a caller that names no variable sends nothing, whatever the environment holds
|
||||
assert!(Options::default().token_env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_url_keeps_query_and_owned_catalog_values() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/download?file=weights&sig=secret", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(socket.try_clone().unwrap());
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).unwrap();
|
||||
assert_eq!(request.trim(), "GET /download?file=weights&sig=secret HTTP/1.1");
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap() == 0 || line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc").unwrap();
|
||||
});
|
||||
let name = String::from("catalog.onnx");
|
||||
let hash = sha_hex(b"abc");
|
||||
let spec = FileSpec { name: &name, size: Some(3), sha256: Some(&hash), max: 3 };
|
||||
let dir = tmp("exact");
|
||||
let mut progress = Vec::new();
|
||||
download_url(&spec, &url, &dir, &quick(), &AtomicBool::new(false), &mut |p| progress.push(p.clone())).unwrap();
|
||||
worker.join().unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(name)).unwrap(), b"abc");
|
||||
assert!(progress.iter().all(|p| !p.mirror.contains("secret")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_file_specs_are_rejected_before_touching_disk() {
|
||||
let dir = tmp("invalid");
|
||||
let base = FileSpec { name: "valid.onnx", size: Some(1), sha256: None, max: 1 };
|
||||
let cancel = AtomicBool::new(false);
|
||||
for name in ["", ".hidden", "../escape", "a/b", "a\\b", "C:escape", "bad\nname", "trailing.", "CON.onnx", "a.part"] {
|
||||
let spec = FileSpec { name, ..base.clone() };
|
||||
assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))), "{name}");
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
for spec in [
|
||||
FileSpec { max: 0, ..base.clone() },
|
||||
FileSpec { size: Some(0), ..base.clone() },
|
||||
FileSpec { size: Some(2), ..base.clone() },
|
||||
FileSpec { sha256: Some("not a SHA"), ..base.clone() },
|
||||
] {
|
||||
assert!(matches!(run(&[spec], &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))));
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
let huge = FileSpec { size: Some(u64::MAX), max: u64::MAX, ..base.clone() };
|
||||
let other = FileSpec { name: "other.onnx", ..huge.clone() };
|
||||
for specs in [vec![huge, other], vec![base.clone(); 257], vec![base.clone(), base]] {
|
||||
assert!(matches!(run(&specs, &[], &dir, &cancel).0, Err(DownloadError::Invalid(_))));
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Interactive crop editing: what dragging a crop handle does.
|
||||
//!
|
||||
//! The crop rectangle lives in the straightened (rotated) frame and must stay inside the rotated
|
||||
//! image. A drag is a *domain operation* on the crop, not a UI detail: the handle being dragged
|
||||
//! decides which edges are anchored, an optional aspect lock decides the shape, and the image
|
||||
//! bounds decide how far the drag may go. [`drag_crop`] answers all three in one place so the app,
|
||||
//! CLI, control channel and MCP agree.
|
||||
|
||||
use crate::{CropGeometry, Point, Rect};
|
||||
|
||||
/// Smallest crop side, as a fraction of the image's shorter side.
|
||||
pub const MIN_CROP_FRACTION: f64 = 0.02;
|
||||
|
||||
/// Most elongated locked crop shape (width:height or its inverse) the drag supports.
|
||||
pub const MAX_RATIO: f64 = 20.0;
|
||||
|
||||
/// A grab point on the crop frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CropHandle {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
Left,
|
||||
/// Drag the whole frame.
|
||||
Move,
|
||||
}
|
||||
|
||||
impl CropHandle {
|
||||
/// The overlay's numbering: 0..3 corners (clockwise from top-left), 4..7 edges (top, right,
|
||||
/// bottom, left), 8 = move.
|
||||
pub fn from_index(i: u8) -> Option<CropHandle> {
|
||||
Some(match i {
|
||||
0 => CropHandle::TopLeft,
|
||||
1 => CropHandle::TopRight,
|
||||
2 => CropHandle::BottomRight,
|
||||
3 => CropHandle::BottomLeft,
|
||||
4 => CropHandle::Top,
|
||||
5 => CropHandle::Right,
|
||||
6 => CropHandle::Bottom,
|
||||
7 => CropHandle::Left,
|
||||
8 => CropHandle::Move,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a command-parameter name (`"topLeft"`, `"top"`, `"move"`, …).
|
||||
pub fn from_name(n: &str) -> Option<CropHandle> {
|
||||
Some(match n {
|
||||
"topLeft" => CropHandle::TopLeft,
|
||||
"topRight" => CropHandle::TopRight,
|
||||
"bottomRight" => CropHandle::BottomRight,
|
||||
"bottomLeft" => CropHandle::BottomLeft,
|
||||
"top" => CropHandle::Top,
|
||||
"right" => CropHandle::Right,
|
||||
"bottom" => CropHandle::Bottom,
|
||||
"left" => CropHandle::Left,
|
||||
"move" => CropHandle::Move,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag `handle` of `start` from `grab` to `pointer` (both normalized, in the straightened frame)
|
||||
/// on a `w × h` image. `aspect` is the locked width/height ratio in pixels, if any.
|
||||
///
|
||||
/// Guarantees (for any input, including hostile numbers):
|
||||
/// - the result lies inside the rotated image;
|
||||
/// - the edges opposite the handle stay where they were (a move keeps the size);
|
||||
/// - with `aspect`, the frame keeps that shape on every handle (when `start` had it);
|
||||
/// - the drag stops at the image edge instead of pushing the frame around, and slides along it.
|
||||
pub fn drag_crop(start: CropGeometry, handle: CropHandle, grab: Point, pointer: Point, w: f64, h: f64, aspect: Option<f64>) -> CropGeometry {
|
||||
let sane = |v: f64| v.is_finite();
|
||||
let r0 = start.rect;
|
||||
if ![w, h, grab.x, grab.y, pointer.x, pointer.y, start.angle, r0.x0, r0.y0, r0.x1, r0.y1].iter().copied().all(sane)
|
||||
|| w <= 0.0
|
||||
|| h <= 0.0
|
||||
|| r0.is_empty()
|
||||
|| aspect.is_some_and(|a| !(a.is_finite() && a > 0.0))
|
||||
{
|
||||
return start;
|
||||
}
|
||||
// Start from a valid frame, then work in pixels of the straightened frame.
|
||||
let mut base = start.constrained(w, h);
|
||||
if base.angle == 0.0 {
|
||||
// absorb the 1e-6 tolerance of `is_within_image` so the exact test below accepts the start
|
||||
let r = base.rect;
|
||||
base.rect = Rect::new(r.x0.clamp(0.0, 1.0), r.y0.clamp(0.0, 1.0), r.x1.clamp(0.0, 1.0), r.y1.clamp(0.0, 1.0));
|
||||
}
|
||||
let sr = base.rect_px(w, h);
|
||||
// Moving further than twice the image never matters; clamping keeps the searches well-conditioned.
|
||||
let (dx, dy) = (((pointer.x - grab.x) * w).clamp(-2.0 * w, 2.0 * w), ((pointer.y - grab.y) * h).clamp(-2.0 * h, 2.0 * h));
|
||||
// Ratios beyond 20:1 make even the minimum frame larger than the image; treat them as 20:1.
|
||||
let aspect = aspect.map(|a| a.clamp(1.0 / MAX_RATIO, MAX_RATIO));
|
||||
// Unrotated, the frame must be exactly inside the image (`is_within_image` tolerates 1e-6, which
|
||||
// would leak out as a sliver past the edge and, after any clamp, a shifted ratio).
|
||||
let feasible = |r: Rect| {
|
||||
if base.angle == 0.0 {
|
||||
r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= w && r.y1 <= h
|
||||
} else {
|
||||
CropGeometry { rect: r.scale(1.0 / w, 1.0 / h), angle: base.angle }.is_within_image(w, h)
|
||||
}
|
||||
};
|
||||
let min_side = MIN_CROP_FRACTION * w.min(h);
|
||||
|
||||
let out = match (handle, aspect) {
|
||||
(CropHandle::Move, _) => {
|
||||
let slide = |cur: Rect, ax: f64, ay: f64| {
|
||||
let t = bisect(0.0, 1.0, |t| feasible(cur.translate(crate::Vec2::new(ax * t, ay * t))));
|
||||
cur.translate(crate::Vec2::new(ax * t, ay * t))
|
||||
};
|
||||
let cur = slide(sr, dx, 0.0);
|
||||
slide(cur, 0.0, dy)
|
||||
}
|
||||
(hd, None) => {
|
||||
let (mx0, mx1, my0, my1) = sides(hd);
|
||||
let grow = |cur: Rect, tx: f64, ty: f64| {
|
||||
// `tx`/`ty`: how far the moving edges would go; interpolate towards it
|
||||
let target = Rect::new(
|
||||
if mx0 { (cur.x0 + tx).min(cur.x1 - min_side) } else { cur.x0 },
|
||||
if my0 { (cur.y0 + ty).min(cur.y1 - min_side) } else { cur.y0 },
|
||||
if mx1 { (cur.x1 + tx).max(cur.x0 + min_side) } else { cur.x1 },
|
||||
if my1 { (cur.y1 + ty).max(cur.y0 + min_side) } else { cur.y1 },
|
||||
);
|
||||
let at = |t: f64| lerp_rect(cur, target, t);
|
||||
at(bisect(0.0, 1.0, |t| feasible(at(t))))
|
||||
};
|
||||
let cur = grow(sr, dx, 0.0);
|
||||
grow(cur, 0.0, dy)
|
||||
}
|
||||
(hd, Some(a)) => {
|
||||
let (mx0, _, my0, _) = sides(hd);
|
||||
let (min_w, min_h) = (min_side.max(min_side * a), min_side.max(min_side / a));
|
||||
let c = sr.center();
|
||||
// Anchor: the opposite corner / edge midpoint. `build(n, o)` is the frame of width `n` that
|
||||
// keeps it, shifted by `o` along the anchored edge (edge handles only).
|
||||
let (target_w, build): (f64, Box<dyn Fn(f64, f64) -> Rect>) = match hd {
|
||||
CropHandle::Top | CropHandle::Bottom => {
|
||||
let (ay, sy, edge) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) };
|
||||
let nh = (sy * (edge + dy - ay)).max(min_h);
|
||||
(nh * a, Box::new(move |nw, o| vband(c.x + o, ay, sy, nw, a)))
|
||||
}
|
||||
CropHandle::Left | CropHandle::Right => {
|
||||
let (ax, sx, edge) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) };
|
||||
let nw = (sx * (edge + dx - ax)).max(min_w);
|
||||
(nw, Box::new(move |nw, o| hband(ax, sx, c.y + o, nw, a)))
|
||||
}
|
||||
_ => {
|
||||
let (ax, sx, ex) = if mx0 { (sr.x1, -1.0, sr.x0) } else { (sr.x0, 1.0, sr.x1) };
|
||||
let (ay, sy, ey) = if my0 { (sr.y1, -1.0, sr.y0) } else { (sr.y0, 1.0, sr.y1) };
|
||||
let (wx, wy) = (sx * (ex + dx - ax), sy * (ey + dy - ay));
|
||||
let nw = ((wx + wy * a) / 2.0).max(min_w);
|
||||
(nw, Box::new(move |nw, _| corner_rect(ax, sx, ay, sy, nw, a)))
|
||||
}
|
||||
};
|
||||
let target_w = target_w.min(2.0 * w.max(h * a));
|
||||
let slides = matches!(hd, CropHandle::Top | CropHandle::Bottom | CropHandle::Left | CropHandle::Right);
|
||||
let extent = if matches!(hd, CropHandle::Top | CropHandle::Bottom) { w } else { h };
|
||||
// Frame of width `n`, centred on the anchored edge if it fits, else slid the least distance.
|
||||
let place = |n: f64| -> Option<Rect> {
|
||||
let at = |o: f64| build(n, o);
|
||||
if feasible(at(0.0)) {
|
||||
return Some(at(0.0));
|
||||
}
|
||||
if !slides {
|
||||
return None;
|
||||
}
|
||||
const STEPS: u32 = 128;
|
||||
let step = extent / STEPS as f64;
|
||||
for k in 1..=STEPS {
|
||||
for sign in [1.0, -1.0] {
|
||||
let hi = sign * k as f64 * step;
|
||||
if feasible(at(hi)) {
|
||||
// closest feasible offset: bisect between the last infeasible and this one
|
||||
let (mut bad, mut good) = (sign * (k - 1) as f64 * step, hi);
|
||||
for _ in 0..40 {
|
||||
let mid = (bad + good) / 2.0;
|
||||
if feasible(at(mid)) {
|
||||
good = mid;
|
||||
} else {
|
||||
bad = mid;
|
||||
}
|
||||
}
|
||||
return Some(at(good));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
if place(min_w).is_some() {
|
||||
let n = bisect_range(min_w, target_w.max(min_w), |n| place(n).is_some());
|
||||
place(n).unwrap_or(sr)
|
||||
} else {
|
||||
sr
|
||||
}
|
||||
}
|
||||
};
|
||||
let rect = out.scale(1.0 / w, 1.0 / h);
|
||||
if [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { CropGeometry { rect, angle: base.angle } } else { start }
|
||||
}
|
||||
|
||||
/// Which sides a handle moves: (left, right, top, bottom).
|
||||
fn sides(h: CropHandle) -> (bool, bool, bool, bool) {
|
||||
use CropHandle::*;
|
||||
(
|
||||
matches!(h, TopLeft | BottomLeft | Left),
|
||||
matches!(h, TopRight | BottomRight | Right),
|
||||
matches!(h, TopLeft | TopRight | Top),
|
||||
matches!(h, BottomLeft | BottomRight | Bottom),
|
||||
)
|
||||
}
|
||||
|
||||
fn lerp_rect(a: Rect, b: Rect, t: f64) -> Rect {
|
||||
let l = |p: f64, q: f64| p + (q - p) * t;
|
||||
Rect::new(l(a.x0, b.x0), l(a.y0, b.y0), l(a.x1, b.x1), l(a.y1, b.y1))
|
||||
}
|
||||
|
||||
/// Frame of width `nw` and ratio `a` extending from corner (`ax`, `ay`) in directions `sx`, `sy`.
|
||||
fn corner_rect(ax: f64, sx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect {
|
||||
let nh = nw / a;
|
||||
Rect::from_points(Point::new(ax, ay), Point::new(ax + sx * nw, ay + sy * nh))
|
||||
}
|
||||
|
||||
/// Frame whose top/bottom edge (at `ay`, growing in direction `sy`) is anchored, centred on `cx`.
|
||||
fn vband(cx: f64, ay: f64, sy: f64, nw: f64, a: f64) -> Rect {
|
||||
let nh = nw / a;
|
||||
Rect::from_points(Point::new(cx - nw / 2.0, ay), Point::new(cx + nw / 2.0, ay + sy * nh))
|
||||
}
|
||||
|
||||
/// Frame whose left/right edge (at `ax`, growing in direction `sx`) is anchored, centred on `cy`.
|
||||
fn hband(ax: f64, sx: f64, cy: f64, nw: f64, a: f64) -> Rect {
|
||||
let nh = nw / a;
|
||||
Rect::from_points(Point::new(ax, cy - nh / 2.0), Point::new(ax + sx * nw, cy + nh / 2.0))
|
||||
}
|
||||
|
||||
/// Largest `t` in `[lo, hi]` for which `ok` holds, given that it holds at `lo` and the set of good
|
||||
/// `t` is an interval (the feasible region is convex).
|
||||
fn bisect_range(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 {
|
||||
if ok(hi) {
|
||||
return hi;
|
||||
}
|
||||
let (mut good, mut bad) = (lo, hi);
|
||||
for _ in 0..48 {
|
||||
let mid = (good + bad) / 2.0;
|
||||
if ok(mid) {
|
||||
good = mid;
|
||||
} else {
|
||||
bad = mid;
|
||||
}
|
||||
}
|
||||
good
|
||||
}
|
||||
|
||||
fn bisect(lo: f64, hi: f64, ok: impl Fn(f64) -> bool) -> f64 {
|
||||
bisect_range(lo, hi, ok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use CropHandle::*;
|
||||
|
||||
const W: f64 = 600.0;
|
||||
const H: f64 = 400.0;
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
fn geo(x0: f64, y0: f64, x1: f64, y1: f64) -> CropGeometry {
|
||||
CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle: 0.0 }
|
||||
}
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
fn drag(g: CropGeometry, hd: CropHandle, from: Point, to: Point, aspect: Option<f64>) -> CropGeometry {
|
||||
drag_crop(g, hd, from, to, W, H, aspect)
|
||||
}
|
||||
fn px_aspect(g: &CropGeometry) -> f64 {
|
||||
g.rect.width() * W / (g.rect.height() * H)
|
||||
}
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < EPS
|
||||
}
|
||||
|
||||
// Feature: dragging a crop handle
|
||||
// Scenario: a free corner drag inside the image
|
||||
#[test]
|
||||
fn given_free_crop_when_corner_dragged_inside_then_corner_follows_and_opposite_corner_stays() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(0.9, 0.7), None);
|
||||
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2));
|
||||
assert!(near(g.rect.x1, 0.9) && near(g.rect.y1, 0.7));
|
||||
}
|
||||
|
||||
// Scenario: dragging past the right edge (the weird behaviour of issue 295)
|
||||
#[test]
|
||||
fn given_corner_dragged_past_image_edge_then_it_stops_at_the_edge_and_anchor_stays() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(1.3, 0.7), None);
|
||||
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "anchor moved: {:?}", g.rect);
|
||||
assert!(near(g.rect.x1, 1.0), "stops at the edge: {:?}", g.rect);
|
||||
assert!(near(g.rect.y1, 0.7), "other axis still follows the pointer: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_locked_aspect_when_corner_dragged_past_edge_then_ratio_and_anchor_hold() {
|
||||
let a = 1.5;
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.2 + 0.6 * W / H / a), BottomRight, p(0.8, 0.6), p(1.4, 0.9), Some(a));
|
||||
assert!(near(g.rect.x0, 0.2) && near(g.rect.y0, 0.2), "{:?}", g.rect);
|
||||
assert!(near(px_aspect(&g), a), "{}", px_aspect(&g));
|
||||
assert!(g.rect.x1 <= 1.0 + EPS && g.rect.y1 <= 1.0 + EPS);
|
||||
assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "grows until it touches an edge: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_locked_aspect_when_edge_dragged_then_ratio_holds_and_frame_grows_about_its_midline() {
|
||||
let a = 2.0;
|
||||
let start = geo(0.3, 0.3, 0.7, 0.3 + 0.4 * W / H / a);
|
||||
let g = drag(start, Bottom, p(0.5, start.rect.y1), p(0.5, start.rect.y1 + 0.1), Some(a));
|
||||
assert!(near(g.rect.y0, start.rect.y0), "opposite edge stays");
|
||||
assert!(near(g.rect.center().x, start.rect.center().x), "grows symmetrically sideways");
|
||||
assert!(near(px_aspect(&g), a));
|
||||
assert!(g.rect.height() > start.rect.height());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_free_crop_when_edge_dragged_then_only_that_edge_moves() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), Left, p(0.2, 0.5), p(0.1, 0.9), None);
|
||||
assert!(near(g.rect.x0, 0.1) && near(g.rect.y0, 0.2) && near(g.rect.x1, 0.8) && near(g.rect.y1, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_move_past_edge_then_size_is_kept_and_frame_slides_along_the_edge() {
|
||||
let start = geo(0.5, 0.25, 0.9, 0.65);
|
||||
let g = drag(start, Move, p(0.7, 0.45), p(1.3, 0.55), None);
|
||||
assert!(near(g.rect.width(), 0.4) && near(g.rect.height(), 0.4));
|
||||
assert!(near(g.rect.x1, 1.0), "flush with the right edge: {:?}", g.rect);
|
||||
assert!(near(g.rect.y0, 0.35), "still follows the pointer vertically: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_edge_dragged_across_the_opposite_edge_then_crop_keeps_a_minimum_size() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), Right, p(0.8, 0.5), p(-0.5, 0.5), None);
|
||||
assert!(g.rect.width() * W >= MIN_CROP_FRACTION * H - EPS && g.rect.x1 > g.rect.x0);
|
||||
assert!(near(g.rect.x0, 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_straightened_image_when_dragged_outside_then_crop_stays_inside_the_rotated_image() {
|
||||
let start = crate::crop_fit_angle(W, H, 12.0, Some(1.5));
|
||||
let c = start.rect.center();
|
||||
for hd in [TopLeft, TopRight, BottomRight, BottomLeft, Top, Right, Bottom, Left, Move] {
|
||||
for aspect in [None, Some(1.5)] {
|
||||
let g = drag(start, hd, c, p(3.0, -3.0), aspect);
|
||||
assert!(g.is_within_image(W, H), "{hd:?} {aspect:?}: {:?}", g.rect);
|
||||
assert_eq!(g.angle, 12.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_hostile_numbers_then_the_start_crop_is_returned_not_a_panic() {
|
||||
let s = geo(0.2, 0.2, 0.8, 0.8);
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(bad, 0.5), None), s);
|
||||
}
|
||||
assert_eq!(drag_crop(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), 0.0, 400.0, None), s);
|
||||
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(f64::NAN)), s);
|
||||
assert_eq!(drag(s, BottomRight, p(0.8, 0.8), p(0.9, 0.9), Some(-1.0)), s);
|
||||
}
|
||||
|
||||
// Scenario: a locked edge drag when the frame already touches the image edge (review finding)
|
||||
#[test]
|
||||
fn given_locked_frame_flush_with_the_top_when_right_edge_dragged_out_then_it_grows_sliding_down() {
|
||||
let a = 1.5;
|
||||
let start = geo(0.2, 0.0, 0.5, 0.3 * W / H * 0.0 + 0.3 * W / (a * H));
|
||||
let g = drag(start, Right, p(0.5, 0.2), p(0.9, 0.2), Some(a));
|
||||
assert!(near(g.rect.x0, 0.2), "anchor edge stays: {:?}", g.rect);
|
||||
assert!(g.rect.width() > start.rect.width() + 0.05, "it must grow: {:?}", g.rect);
|
||||
assert!(near(px_aspect(&g), a));
|
||||
assert!(g.is_within_image(W, H));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_full_fit_straightened_crop_when_locked_edge_dragged_then_it_still_respects_the_image() {
|
||||
let start = crate::crop_fit_angle(W, H, 20.0, Some(1.5));
|
||||
let g = drag(start, Right, p(0.9, 0.5), p(1.5, 0.5), Some(1.5));
|
||||
assert!(g.is_within_image(W, H) && near(px_aspect(&g), 1.5));
|
||||
}
|
||||
|
||||
// Scenario: extreme or hostile magnitudes
|
||||
#[test]
|
||||
fn given_an_extreme_locked_ratio_then_the_frame_can_still_be_moved() {
|
||||
let start = geo(0.1, 0.4, 0.9, 0.4 + 0.8 * W / (60.0 * H));
|
||||
let g = drag(start, Move, p(0.5, 0.4), p(0.5, 0.7), Some(60.0));
|
||||
assert!(g.rect.y0 > 0.5, "moved: {:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_a_huge_finite_pointer_then_a_locked_corner_still_grows_to_the_edge() {
|
||||
let a = 1.5;
|
||||
let start = geo(0.2, 0.2, 0.5, 0.2 + 0.3 * W / (a * H));
|
||||
let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), Some(a));
|
||||
assert!(g.rect.x1 > 0.95 || g.rect.y1 > 0.95, "{:?}", g.rect);
|
||||
let g = drag(start, BottomRight, p(0.5, 0.4), p(1e300, 1e300), None);
|
||||
assert!(near(g.rect.x1, 1.0) && near(g.rect.y1, 1.0), "{:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_an_unrotated_image_then_results_never_leave_the_unit_square() {
|
||||
let g = drag(geo(0.2, 0.2, 0.8, 0.8), BottomRight, p(0.8, 0.8), p(5.0, 5.0), None);
|
||||
assert!(g.rect.x1 <= 1.0 && g.rect.y1 <= 1.0 && g.rect.x0 >= 0.0 && g.rect.y0 >= 0.0, "{:?}", g.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_numbering_matches_the_overlay() {
|
||||
assert_eq!(CropHandle::from_index(0), Some(TopLeft));
|
||||
assert_eq!(CropHandle::from_index(6), Some(Bottom));
|
||||
assert_eq!(CropHandle::from_index(8), Some(Move));
|
||||
assert_eq!(CropHandle::from_index(9), None);
|
||||
assert_eq!(CropHandle::from_name("bottomLeft"), Some(BottomLeft));
|
||||
assert_eq!(CropHandle::from_name("nope"), None);
|
||||
}
|
||||
|
||||
mod props {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn any_handle() -> impl Strategy<Value = CropHandle> {
|
||||
(0u8..9).prop_map(|i| CropHandle::from_index(i).unwrap_or(Move))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Whatever the pointer does, the crop stays valid; the anchor and the ratio hold.
|
||||
#[test]
|
||||
fn drag_is_always_inside_anchored_and_shaped(
|
||||
angle in -45.0f64..45.0,
|
||||
ratio in prop::option::of(0.3f64..4.0),
|
||||
hd in any_handle(),
|
||||
tx in -4.0f64..5.0, ty in -4.0f64..5.0,
|
||||
shrink in 0.2f64..1.0,
|
||||
) {
|
||||
let base = crate::crop_fit_angle(W, H, angle, ratio);
|
||||
// a smaller start crop so there is room to move
|
||||
let c = base.rect.center();
|
||||
let start = CropGeometry { rect: Rect::from_center(c, base.rect.width() * shrink, base.rect.height() * shrink), angle };
|
||||
prop_assume!(start.is_within_image(W, H));
|
||||
let g = drag_crop(start, hd, c, p(tx, ty), W, H, ratio);
|
||||
prop_assert!(g.is_within_image(W, H), "{:?} -> {:?}", start.rect, g.rect);
|
||||
prop_assert!(g.rect.width() > 0.0 && g.rect.height() > 0.0);
|
||||
let min = MIN_CROP_FRACTION * W.min(H) - 1e-6;
|
||||
prop_assert!(g.rect.width() * W >= min.min(start.rect.width() * W) && g.rect.height() * H >= min.min(start.rect.height() * H), "min size {:?}", g.rect);
|
||||
if let Some(a) = ratio {
|
||||
prop_assert!((px_aspect(&g) - a).abs() < 1e-6 * a.max(1.0), "ratio {a}: {}", px_aspect(&g));
|
||||
}
|
||||
// anchors: edges opposite the handle do not move
|
||||
let (s, r) = (start.rect, g.rect);
|
||||
let keep_l = matches!(hd, TopRight | BottomRight | Right);
|
||||
let keep_r = matches!(hd, TopLeft | BottomLeft | Left);
|
||||
let keep_t = matches!(hd, BottomLeft | BottomRight | Bottom);
|
||||
let keep_b = matches!(hd, TopLeft | TopRight | Top);
|
||||
{
|
||||
if keep_l { prop_assert!((r.x0 - s.x0).abs() < 1e-6, "x0"); }
|
||||
if keep_r { prop_assert!((r.x1 - s.x1).abs() < 1e-6, "x1"); }
|
||||
if keep_t { prop_assert!((r.y0 - s.y0).abs() < 1e-6, "y0"); }
|
||||
if keep_b { prop_assert!((r.y1 - s.y1).abs() < 1e-6, "y1"); }
|
||||
}
|
||||
if hd == Move {
|
||||
prop_assert!((r.width() - s.width()).abs() < 1e-9 && (r.height() - s.height()).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_never_panics_on_arbitrary_numbers(
|
||||
x0 in -2.0f64..3.0, y0 in -2.0f64..3.0, x1 in -2.0f64..3.0, y1 in -2.0f64..3.0,
|
||||
angle in -90.0f64..90.0, tx in -1e9f64..1e9, ty in -1e9f64..1e9,
|
||||
ratio in prop::option::of(-1.0f64..10.0), hd in any_handle(),
|
||||
) {
|
||||
let g = drag_crop(CropGeometry { rect: Rect::new(x0, y0, x1, y1), angle }, hd, p(0.5, 0.5), p(tx, ty), W, H, ratio);
|
||||
prop_assert!(g.rect.x0.is_finite() && g.rect.y1.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod crop;
|
||||
mod crop_drag;
|
||||
mod homography;
|
||||
mod real;
|
||||
|
||||
pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};
|
||||
pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop};
|
||||
pub use homography::Homography;
|
||||
pub use real::{Interval, Real};
|
||||
|
||||
|
||||
@@ -155,8 +155,16 @@ pub(crate) fn with_init_marker<T>(backends: Backends, f: impl FnOnce() -> T) ->
|
||||
/// If the init marker `path` is present — the last process died while creating the GPU device —
|
||||
/// remove it and return what it recorded.
|
||||
pub fn take_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let text = read_init_marker(path)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// What the init marker `path` recorded, if it is present — without removing it. For sessions
|
||||
/// that must leave the disk as they found it (`--memory`, issues #164 and #169): GPU rendering
|
||||
/// still starts off, and the marker stays for the next ordinary launch to report and clear.
|
||||
pub fn read_init_marker(path: &std::path::Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
Some(text.trim().to_string())
|
||||
}
|
||||
|
||||
@@ -225,10 +233,13 @@ mod tests {
|
||||
set_init_marker(None);
|
||||
assert!(seen, "the marker exists while the device is created");
|
||||
assert!(!m.exists(), "and is removed afterwards");
|
||||
// a marker left behind by a crashed process is reported once
|
||||
// a marker left behind by a crashed process is reported once; reading it leaves it in place
|
||||
std::fs::write(&m, "GPU device creation started (backends DX12)").unwrap();
|
||||
assert!(read_init_marker(&m).unwrap().contains("DX12"));
|
||||
assert!(m.exists(), "read_init_marker leaves the marker for the next launch");
|
||||
assert!(take_init_marker(&m).unwrap().contains("DX12"));
|
||||
assert_eq!(take_init_marker(&m), None);
|
||||
assert_eq!(read_init_marker(&m), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -905,7 +905,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
Some(2)
|
||||
}
|
||||
MaskShape::ColorRange { samples, refine } => {
|
||||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||||
let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine);
|
||||
p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]);
|
||||
aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32)));
|
||||
Some(3)
|
||||
|
||||
@@ -639,3 +639,24 @@ fn output_spaces_match() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_directions_match_cpu_for_raw_and_rendered_sources() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = Arc::new(Rgb32f::filled(64, 64, [0.18; 3]));
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
for delta in [-50.0, 50.0] {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
check("tint direction", &src, &info, &s, &RenderRequest::fit(64, 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ impl Headless {
|
||||
fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch};
|
||||
let p = &self.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
if !ExportOptions::has_size_param(p) {
|
||||
opts.resize = Some(Resize::long_edge(3000));
|
||||
}
|
||||
@@ -107,7 +107,7 @@ impl Backend for Headless {
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?, limitKb?, colorSpace?, bitDepth?, sharpen?, metadata?, watermark?, naming?}",
|
||||
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
|
||||
@@ -120,6 +120,25 @@ fn path_writes_never_replace_an_original() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless
|
||||
/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default.
|
||||
#[test]
|
||||
fn export_refuses_unknown_params_and_bad_values() {
|
||||
let mut b = Headless::demo();
|
||||
let out = "/nonexistent-lc-test/x.jpg";
|
||||
let e = b.call("app.export", json!({"path": out, "longEdgee": 400})).unwrap_err();
|
||||
assert!(e.contains("unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
|
||||
let e = b.call("app.export", json!({"path": out, "longEdge": "banana"})).unwrap_err();
|
||||
assert!(e.contains("`longEdge` must be a number"), "{e}");
|
||||
let e = b.call("app.export", json!({"path": out, "watermark": {"text": "x", "size": 3}})).unwrap_err();
|
||||
assert!(e.contains("`watermark.size` must be a number 0.005..0.5"), "{e}");
|
||||
// `export` tool and preset expansion go through the same check
|
||||
let r = call_tool(&mut b, "export", &json!({"path": out, "quality": 101}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
let e = b.session.execute("export.savePreset", &json!({"name": "Typo", "params": {"qualty": 5}})).unwrap_err().to_string();
|
||||
assert!(e.contains("did you mean `quality`"), "{e}");
|
||||
}
|
||||
|
||||
/// The `import` helper moves: renamed into the folder template, the source removed.
|
||||
#[test]
|
||||
fn import_tool_moves_with_a_folder_template() {
|
||||
@@ -205,3 +224,24 @@ fn resources() {
|
||||
}
|
||||
assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002);
|
||||
}
|
||||
|
||||
/// `select_photos` with an id that is not in the library is a tool error, and the photo that was
|
||||
/// active stays active (#182).
|
||||
#[test]
|
||||
fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
let r = call_tool(&mut b, "select_photos", &json!({"ids": [first.0]}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "select_photos", &json!({"ids": [9999]}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert!(r.content[0]["text"].as_str().unwrap().contains("no such photo 9999"), "{r:?}");
|
||||
assert_eq!(b.session.active(), Some(first));
|
||||
let r = call_tool(&mut b, "get_develop", &json!({}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "get_develop", &json!({"id": 9999}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "set_develop", &json!({"id": 9999, "values": {"light.exposure": 1.0}}));
|
||||
assert!(r.is_error, "{r:?}");
|
||||
assert_eq!(b.session.active(), Some(first));
|
||||
}
|
||||
@@ -102,6 +102,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"},
|
||||
"destination": {"type": "string", "description": "Copy or move: destination folder"},
|
||||
"organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"},
|
||||
"onDeleted": {"type": "string", "enum": ["skip", "restore", "fresh"], "description": "A file that is in Recently Deleted: skip (default) = leave it there (the duplicate is reported with existingDeleted); restore = bring the photo back with its edits (`restored` lists it); fresh = delete the trashed record and import the file as a new photo"},
|
||||
"rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"}
|
||||
}),
|
||||
&["paths"],
|
||||
@@ -205,7 +206,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"removeLocation": {"type": "boolean"},
|
||||
"colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"},
|
||||
"bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"},
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
|
||||
"watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -475,7 +476,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
return ToolResult::error(format!("no photos found in {paths:?}"));
|
||||
}
|
||||
let mut p = json!({"paths": files});
|
||||
for k in ["album", "mode", "destination", "organize", "rename"] {
|
||||
for k in ["album", "mode", "destination", "organize", "rename", "onDeleted"] {
|
||||
if let Some(v) = args.get(k).filter(|v| !v.is_null()) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
|
||||
@@ -111,9 +111,7 @@ pub fn load_frame(bytes: &[u8], max_edge: Option<usize>, orient: bool) -> Result
|
||||
if lightcraft_raw::probe(bytes).is_some() {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
// geometric lens corrections stay with the develop settings ("profile corrections")
|
||||
raw.opcodes
|
||||
.list3
|
||||
.retain(|op| !matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. }));
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let small = max_edge.is_some_and(|m| m <= 1200);
|
||||
let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||||
let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?;
|
||||
|
||||
@@ -191,3 +191,28 @@ mod bw_tests {
|
||||
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tint_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_wb_and_picker_correct_green_with_positive_tint() {
|
||||
// The picker delegates a sampled patch to this same auto_wb implementation.
|
||||
for (rgb, sign) in [([0.18, 0.24, 0.18], 1.0), ([0.24, 0.18, 0.24], -1.0)] {
|
||||
let img = Rgb32f::filled(16, 16, rgb);
|
||||
let info = SourceInfo { raw: true, relative_wb: true, ..Default::default() };
|
||||
let (temp, tint) = auto_wb(&img, &info);
|
||||
assert!(tint * sign > 0.0, "{rgb:?}: temp {temp}, tint {tint}");
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = lightcraft_develop::WbMode::Custom;
|
||||
s.wb.temp = temp;
|
||||
s.wb.tint = tint;
|
||||
let mut corrected = img;
|
||||
crate::local::white_balance(&mut corrected, &info, &s);
|
||||
let p = corrected.get(0, 0);
|
||||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||||
assert!(spread < 0.003, "{rgb:?} -> {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
}
|
||||
}
|
||||
MaskShape::ColorRange { samples, refine } => {
|
||||
let tol = 0.04 + 0.16 * (*refine as f32 / 100.0);
|
||||
let tol = color_range_tolerance(*refine);
|
||||
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
|
||||
for (v, c) in out.data.iter_mut().zip(&img.data) {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
|
||||
@@ -257,6 +257,21 @@ pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
|
||||
c.map(|v| v / (1.0 + v))
|
||||
}
|
||||
|
||||
/// Colour range: the OkLab distance (a, b at full weight, L at a quarter) at which a pixel leaves
|
||||
/// the selection for a Refine amount of 0..100. A pixel is fully selected up to half of it and
|
||||
/// fades out over the other half (CPU here, `mask.wgsl` kind 3; both take this value).
|
||||
///
|
||||
/// Issue #157: `0.04 + 0.16·refine` selected the whole photo. Through [`tonemap_for_select`] a
|
||||
/// slightly teal mid-grey (sRGB 120,150,155) and a neutral light grey (200,198,195) are only ~0.063
|
||||
/// apart — L 0.60 vs 0.72, a/b ≤ 0.02 — so at the default Refine 50 (full selection below 0.06)
|
||||
/// the grey patch was 99 % selected, and at Refine 85 / 100 still most of it. A JND is ~0.01 on
|
||||
/// this scale, so Refine 50 now holds a surface up to ~2 JND from its sample and drops everything
|
||||
/// beyond ~4.5 (full to 0.0225, gone at 0.045); Refine 85 is gone at 0.066, past that grey patch;
|
||||
/// Refine 100 reaches 0.075 and Refine 0 is the sample's own shade (full to 0.0075, gone at 0.015).
|
||||
pub fn color_range_tolerance(refine: f64) -> f32 {
|
||||
0.015 + 0.06 * (refine / 100.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// A brush stroke resolved to output pixels: dab centres (spacing r/4 along the path), radius,
|
||||
/// hard-core radius, flow and density (0..1), and whether it erases.
|
||||
pub struct BrushDabs {
|
||||
@@ -598,4 +613,42 @@ mod tests {
|
||||
let (plain, sharp) = (step(&soft), step(&refined));
|
||||
assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}");
|
||||
}
|
||||
|
||||
/// Issue #157: a colour-range sample of one flat patch must not select a clearly different
|
||||
/// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195),
|
||||
/// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab).
|
||||
#[test]
|
||||
fn color_range_keeps_to_the_sampled_colour() {
|
||||
use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8};
|
||||
let (w, h) = (120usize, 40usize);
|
||||
let m = SRGB.to_space(&REC2020);
|
||||
let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8));
|
||||
let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195]));
|
||||
let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right });
|
||||
let log_l = img.map(crate::local::log_lum);
|
||||
let f = Frame::new(w, h, &Default::default(), true);
|
||||
let mut sample = [0f64; 3];
|
||||
for y in 19..=21 {
|
||||
for x in 29..=31 {
|
||||
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x]));
|
||||
for k in 0..3 {
|
||||
sample[k] += lab[k] as f64 / 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let alpha = |refine| {
|
||||
let shape = MaskShape::ColorRange { samples: vec![sample], refine };
|
||||
let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0);
|
||||
(a.get(30, 20), a.get(90, 20))
|
||||
};
|
||||
for refine in [0.0, 50.0, 85.0] {
|
||||
let (l, r) = alpha(refine);
|
||||
assert!(l > 0.95, "refine {refine}: sampled patch {l}");
|
||||
assert!(r < 0.05, "refine {refine}: other patch {r}");
|
||||
}
|
||||
// Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the
|
||||
// other patch stays mostly out
|
||||
let (l, r) = alpha(100.0);
|
||||
assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
//! dimensionless scales), so a 400 px preview and a full-size export are warped identically.
|
||||
//!
|
||||
//! Also here: automatic lateral chromatic aberration estimation ([`estimate_lateral_ca`]) and [`defringe`].
|
||||
//! Profile corrections use only lens data embedded in DNG files (`WarpRectilinear` / `FixVignetteRadial`).
|
||||
//! Profile corrections use only lens data embedded in the files: DNG `WarpRectilinear` / `FixVignetteRadial`, and the
|
||||
//! raw readers' equivalent of a camera's own correction (Panasonic / Leica RW2 distortion).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
|
||||
@@ -330,3 +330,28 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
|
||||
assert!(blue(&pro) > 0);
|
||||
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_negative_is_green_and_positive_is_magenta() {
|
||||
use lightcraft_develop::WbMode;
|
||||
let src = Rgb32f::filled(16, 16, [0.18; 3]);
|
||||
// Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint.
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
let mut s = DevelopSettings::default();
|
||||
let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
for delta in [-50.0, 50.0] {
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64;
|
||||
assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub use lightcraft_geom::Orientation;
|
||||
pub use lightcraft_meta::Metadata;
|
||||
pub use lightcraft_raster::Rgb32f;
|
||||
pub use opcodes::{Opcode, OpcodeLists};
|
||||
pub use preview::embedded_preview;
|
||||
pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space};
|
||||
|
||||
use lightcraft_color::Xy;
|
||||
use lightcraft_tiff::{Tiff, TiffError};
|
||||
@@ -159,9 +159,9 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
/// measure them from the samples).
|
||||
///
|
||||
/// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon
|
||||
/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF
|
||||
/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to
|
||||
/// decompress) and dropped.
|
||||
/// NEF: optically masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA
|
||||
/// phase of files without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders); for
|
||||
/// those the samples are read (unpacked, nothing to decompress) and dropped.
|
||||
pub fn probe_info(bytes: &[u8]) -> Result<RawInfo> {
|
||||
decode_with(bytes, Mode::Header).map(RawImage::into_info)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
RawFormat::Raf => vendor::raf::decode(bytes, mode),
|
||||
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
|
||||
RawFormat::Pef => vendor::pef::decode(bytes, mode),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes, mode),
|
||||
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,13 @@ impl Opcode {
|
||||
pub fn is_applied(&self) -> bool {
|
||||
!matches!(self, Opcode::Unknown { .. } | Opcode::TrimBounds { .. } | Opcode::WarpFisheye { .. })
|
||||
}
|
||||
|
||||
/// Whether this is a lens correction (`WarpRectilinear`, `FixVignetteRadial`): from a DNG, or the equivalent of
|
||||
/// a camera's own correction recorded in its raw (Panasonic RW2). Developers that apply these through the
|
||||
/// "profile corrections" of the develop settings remove them before demosaicing.
|
||||
pub fn is_lens_correction(&self) -> bool {
|
||||
matches!(self, Opcode::WarpRectilinear { .. } | Opcode::FixVignetteRadial { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// The three opcode lists of a DNG raw IFD.
|
||||
|
||||
@@ -5,6 +5,30 @@ use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::tags as t;
|
||||
use lightcraft_tiff::{Ifd, Tiff, makernote};
|
||||
|
||||
/// Colour space of an embedded JPEG when the enclosing raw supplies it instead of the JPEG.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PreviewColorSpace {
|
||||
Srgb,
|
||||
AdobeRgb,
|
||||
}
|
||||
|
||||
/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation.
|
||||
/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence.
|
||||
pub fn embedded_preview_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
|
||||
let tiff = Tiff::parse(bytes).ok()?;
|
||||
let make = tiff.ifds.first()?.string(t::MAKE)?;
|
||||
if !make.to_ascii_uppercase().starts_with("NIKON") {
|
||||
return None;
|
||||
}
|
||||
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
|
||||
let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
|
||||
match mn.ifd.u16(0x001e)? {
|
||||
1 => Some(PreviewColorSpace::Srgb),
|
||||
2 => Some(PreviewColorSpace::AdobeRgb),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
|
||||
fn is_dct_jpeg(b: &[u8]) -> bool {
|
||||
if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 {
|
||||
|
||||
Vendored
+53
-3
@@ -17,6 +17,19 @@ use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote};
|
||||
const WB_RB_LEVELS: u16 = 0x000c;
|
||||
const BLACK_LEVEL: u16 = 0x003d;
|
||||
const LINEARIZATION_TABLE: u16 = 0x0096;
|
||||
const CROP_AREA: u16 = 0x0045;
|
||||
|
||||
/// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation).
|
||||
/// Keep the active area/CFA origin unchanged: the default crop is applied after demosaicing.
|
||||
fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
|
||||
let fallback = Rect::new(0, 0, w, h);
|
||||
let Some(values) = mn.and_then(|m| m.ifd.u64s(CROP_AREA)) else { return fallback };
|
||||
let [x, y, width, height] = values.as_slice() else { return fallback };
|
||||
if *width == 0 || *height == 0 || x.checked_add(*width).is_none_or(|v| v > w as u64) || y.checked_add(*height).is_none_or(|v| v > h as u64) {
|
||||
return fallback;
|
||||
}
|
||||
Rect::new(*x as usize, *y as usize, *width as usize, *height as usize)
|
||||
}
|
||||
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.all_ifds()
|
||||
@@ -80,9 +93,10 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
};
|
||||
let white = white_from_data(samples, bits);
|
||||
let active_w = trailing_masked_columns(samples, w, h, white);
|
||||
let crop = default_crop(mn.as_ref(), active_w, h);
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(active_w as u32);
|
||||
metadata.height = Some(h as u32);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let img = RawImage {
|
||||
format: RawFormat::Nef,
|
||||
width: w,
|
||||
@@ -94,7 +108,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
black,
|
||||
white: vec![white],
|
||||
active_area: Rect::new(0, 0, active_w, h),
|
||||
crop: Rect::new(0, 0, active_w, h),
|
||||
crop,
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: wb,
|
||||
@@ -181,6 +195,42 @@ mod tests {
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_color_space_is_read_from_the_nikon_note() {
|
||||
for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] {
|
||||
let mut mn = IfdBuilder::new();
|
||||
mn.set(0x001e, Value::Short(vec![value]));
|
||||
let bytes = nef_with_note(1, 14, vec![vec![0; 128]], 8, 8, 8, Some(mn));
|
||||
assert_eq!(crate::embedded_preview_color_space(&bytes), expected);
|
||||
}
|
||||
assert_eq!(crate::embedded_preview_color_space(&nef(1, 14, vec![vec![0; 128]], 8, 8, 8)), None);
|
||||
assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() {
|
||||
let (w, h) = (32, 24);
|
||||
let words: Vec<u8> = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect();
|
||||
for (values, expected) in [
|
||||
(vec![2, 4, 28, 16], Rect::new(2, 4, 28, 16)),
|
||||
(vec![31, 4, 28, 16], Rect::new(0, 0, 32, 24)),
|
||||
(vec![2, 4, 0, 16], Rect::new(0, 0, 32, 24)),
|
||||
(vec![u32::MAX, 4, 28, 16], Rect::new(0, 0, 32, 24)),
|
||||
(vec![2, 4, 28], Rect::new(0, 0, 32, 24)),
|
||||
] {
|
||||
let mut mn = IfdBuilder::new();
|
||||
mn.set(CROP_AREA, Value::Long(values));
|
||||
let bytes = nef_with_note(1, 14, vec![words.clone()], w, h, h, Some(mn));
|
||||
let raw = crate::decode(&bytes).unwrap();
|
||||
assert_eq!(raw.crop, expected);
|
||||
assert_eq!(raw.active_area, Rect::new(0, 0, 32, 24));
|
||||
assert_eq!(raw.metadata.width, Some(expected.width as u32));
|
||||
assert_eq!(raw.metadata.height, Some(expected.height as u32));
|
||||
let developed = raw.develop(crate::Method::Bilinear).unwrap();
|
||||
assert_eq!((developed.width, developed.height), (expected.width, expected.height));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_level_tag_is_in_14_bit_units() {
|
||||
let (w, h) = (16usize, 4usize);
|
||||
|
||||
Vendored
+178
-24
@@ -1,10 +1,10 @@
|
||||
//! Olympus ORF — uncompressed variants.
|
||||
//!
|
||||
//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), the ExifTool
|
||||
//! Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — `0x0101/0x0102`
|
||||
//! PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` BlackLevel2,
|
||||
//! `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from raw.pixls.us
|
||||
//! (E-1, E-400, XZ-2):
|
||||
//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), Exif 2.3 (`CFAPattern`,
|
||||
//! tag `0xa302`), the ExifTool Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings —
|
||||
//! `0x0101/0x0102` PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600`
|
||||
//! BlackLevel2, `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from
|
||||
//! raw.pixls.us (E-1, E-400, XZ-2):
|
||||
//!
|
||||
//! - 16 bits per sample: little-endian words; some bodies (E-1, E-400) store 12-bit values in the top bits (the low
|
||||
//! four bits zero in more than 99% of samples), which we shift down.
|
||||
@@ -12,14 +12,16 @@
|
||||
//! candidate bit orders for the smoothest image).
|
||||
//! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively
|
||||
//! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works.
|
||||
//! - The files carry no CFA tag. The green diagonal is found from the data (the diagonal whose two samples differ
|
||||
//! least); bodies with greens on the main diagonal (E-1, E-400) are GRBG, the others (XZ-2) RGGB — verified by
|
||||
//! colour renders.
|
||||
//! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where
|
||||
//! the colours of the decoded mosaic follow the embedded JPEG best with exactly that layout
|
||||
//! (`corpus_orf_cfa_patterns`). The samples' active areas start at even offsets, so they can't tell whether the
|
||||
//! pattern is anchored at the sensor origin (assumed) or at the active area. Files without a usable tag fall back
|
||||
//! to the green diagonal found from the data, which can't tell red from blue (GRBG or RGGB is assumed).
|
||||
|
||||
use super::white_from_data;
|
||||
use crate::tiffraw::{Packing, read_image};
|
||||
use crate::unpack::unpack_msb;
|
||||
use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::makernote::MakerNote;
|
||||
@@ -33,6 +35,8 @@ pub(crate) const PREVIEW_LENGTH: u16 = 0x0102;
|
||||
const WB_RB: u16 = 0x0100;
|
||||
const BLACK: u16 = 0x0600;
|
||||
const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615];
|
||||
/// Exif `CFAPattern`.
|
||||
const EXIF_CFA_PATTERN: u16 = 0xa302;
|
||||
|
||||
/// The Olympus maker note.
|
||||
pub(crate) fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<MakerNote> {
|
||||
@@ -66,23 +70,52 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) {
|
||||
unpack_msb(&swapped, bits, out);
|
||||
}
|
||||
|
||||
/// GRBG when the greens sit on the main diagonal of the 2×2 cell at the sensor origin, else RGGB.
|
||||
/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either
|
||||
/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell.
|
||||
fn cfa_from_exif(tiff: &Tiff) -> Option<Cfa> {
|
||||
let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None };
|
||||
let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2));
|
||||
let name = match [s0, s1, s2, s3] {
|
||||
[0, 1, 1, 2] => "RGGB",
|
||||
[2, 1, 1, 0] => "BGGR",
|
||||
[1, 0, 2, 1] => "GRBG",
|
||||
[1, 2, 0, 1] => "GBRG",
|
||||
_ => return None,
|
||||
};
|
||||
(two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name))
|
||||
}
|
||||
|
||||
/// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main
|
||||
/// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both
|
||||
/// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the
|
||||
/// same light, red and blue rarely do. Totals rather than single pixels, so that fine texture, which makes
|
||||
/// neighbouring greens differ, doesn't outweigh a small difference between red and blue.
|
||||
pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa {
|
||||
let (x0, y0) = (a.x + ((a.width / 8) & !1), a.y + ((a.height / 8) & !1));
|
||||
let (x1, y1) = (a.x + a.width * 7 / 8, a.y + a.height * 7 / 8);
|
||||
const BLOCK: usize = 32;
|
||||
let (x0, y0) = (a.x.saturating_add(1) & !1, a.y.saturating_add(1) & !1);
|
||||
let across = a.x.saturating_add(a.width).min(w).saturating_sub(x0) / BLOCK;
|
||||
let down = a.y.saturating_add(a.height).saturating_sub(y0) / BLOCK;
|
||||
let (mut main, mut anti) = (0u64, 0u64);
|
||||
for y in (y0..y1.saturating_sub(1)).step_by(16) {
|
||||
for x in (x0..x1.saturating_sub(1)).step_by(8) {
|
||||
let (x, y) = (x & !1, y & !1);
|
||||
let p = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as i64;
|
||||
main += (p(0, 0) - p(1, 1)).unsigned_abs();
|
||||
anti += (p(1, 0) - p(0, 1)).unsigned_abs();
|
||||
for by in (0..down).step_by(4) {
|
||||
for bx in (0..across).step_by(4) {
|
||||
let mut sums = [0i64; 4];
|
||||
for y in 0..BLOCK {
|
||||
let row = (y0 + by * BLOCK + y).checked_mul(w).and_then(|r| r.checked_add(x0 + bx * BLOCK));
|
||||
// rows past the end of the samples: nothing more to read
|
||||
let Some(row) = row.and_then(|start| d.get(start..start.checked_add(BLOCK)?)) else { break };
|
||||
for (x, &v) in row.iter().enumerate() {
|
||||
sums[(y & 1) * 2 + (x & 1)] += v as i64;
|
||||
}
|
||||
}
|
||||
let [s0, s1, s2, s3] = sums;
|
||||
main += (s0 - s3).unsigned_abs();
|
||||
anti += (s1 - s2).unsigned_abs();
|
||||
}
|
||||
}
|
||||
Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" })
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
let info = ifd0.image()?;
|
||||
@@ -90,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let total: u64 = chunks.iter().map(|c| c.len).sum();
|
||||
let stated = cfa_from_exif(&tiff);
|
||||
let (mut data, bits) = if info.compression != 1 {
|
||||
return Err(RawError::Unsupported(format!("ORF compression {}", info.compression)));
|
||||
} else if total >= (n as u64) * 2 {
|
||||
@@ -97,9 +131,16 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
(d, 16)
|
||||
} else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 {
|
||||
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?;
|
||||
let stride = src.len() / h;
|
||||
let mut d = vec![0u16; n];
|
||||
d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row));
|
||||
// the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its
|
||||
// colour-filter layout
|
||||
let d = if mode == Mode::Full || stated.is_none() {
|
||||
let stride = src.len() / h;
|
||||
let mut d = vec![0u16; n];
|
||||
d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row));
|
||||
d
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(RawData::U16(d), 12)
|
||||
} else {
|
||||
return Err(RawError::Unsupported("Olympus compressed ORF".into()));
|
||||
@@ -128,7 +169,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
Some([Some(x), Some(y), Some(cw), Some(ch)]) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= h => Rect::new(x, y, cw, ch),
|
||||
_ => Rect::new(0, 0, w, h),
|
||||
};
|
||||
let cfa = cfa_from_data(samples, w, active);
|
||||
let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active));
|
||||
let black = match ip.as_ref().and_then(|i| i.f64s(BLACK)).as_deref() {
|
||||
Some(v @ [_, _, _, _]) => {
|
||||
let a = cfa.shifted(active.x, active.y);
|
||||
@@ -165,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
};
|
||||
img.validate()?;
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
@@ -185,6 +226,11 @@ mod tests {
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter};
|
||||
|
||||
fn orf(w: u32, h: u32, bits: u16, strip: Vec<u8>) -> Vec<u8> {
|
||||
orf_with(w, h, bits, strip, None)
|
||||
}
|
||||
|
||||
/// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`.
|
||||
fn orf_with(w: u32, h: u32, bits: u16, strip: Vec<u8>, cfa_pattern: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
@@ -193,12 +239,120 @@ mod tests {
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![1]));
|
||||
ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into()));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] });
|
||||
if let Some(p) = cfa_pattern {
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec()));
|
||||
ifd.set_child(t::EXIF_IFD, exif);
|
||||
}
|
||||
let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap();
|
||||
b[2] = b'R';
|
||||
b[3] = b'O';
|
||||
b
|
||||
}
|
||||
|
||||
/// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy).
|
||||
fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec<u8> {
|
||||
(0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Smooth scene texture shared by the four sites of a cell.
|
||||
fn shade(cx: usize, cy: usize) -> u16 {
|
||||
((cx * 7 + cy * 13) % 23) as u16
|
||||
}
|
||||
|
||||
const BGGR: &[u8] = &[2, 0, 2, 0, 2, 1, 1, 0];
|
||||
const GRBG: &[u8] = &[2, 0, 2, 0, 1, 0, 2, 1];
|
||||
// repeat counts in the other byte order
|
||||
const RGGB: &[u8] = &[0, 2, 0, 2, 0, 1, 1, 2];
|
||||
const GBRG: &[u8] = &[0, 2, 0, 2, 1, 2, 0, 1];
|
||||
|
||||
fn layout(bytes: &[u8]) -> String {
|
||||
let r = crate::decode(bytes).unwrap();
|
||||
assert_eq!(crate::probe_info(bytes).unwrap(), r.info());
|
||||
r.cfa.unwrap().name()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_cfa_pattern_states_the_layout() {
|
||||
let (w, h) = (160usize, 144usize);
|
||||
// samples whose green diagonal alone would say RGGB
|
||||
let px = mosaic(w, h, |cx, cy| [600, 1000, 1000, 300].map(|v| v + shade(cx, cy)));
|
||||
for (tag, name) in [(BGGR, "BGGR"), (GRBG, "GRBG"), (RGGB, "RGGB"), (GBRG, "GBRG")] {
|
||||
assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name);
|
||||
}
|
||||
// not a 2×2 Bayer cell (four greens, greens side by side, a fourth colour), other repeat counts, wrong
|
||||
// lengths: the samples decide
|
||||
let unusable: [&[u8]; 8] = [
|
||||
&[2, 0, 2, 0, 1, 1, 1, 1],
|
||||
&[2, 0, 2, 0, 1, 1, 0, 2],
|
||||
&[2, 0, 2, 0, 0, 1, 1, 3],
|
||||
&[3, 0, 3, 0, 0, 1, 1, 2],
|
||||
&[2, 2, 2, 0, 0, 1, 1, 2],
|
||||
&[2, 0, 2, 0, 1, 0],
|
||||
&[2, 0, 2, 0, 2, 1, 1, 0, 0],
|
||||
&[],
|
||||
];
|
||||
for (site, name) in [([600, 1000, 1000, 300], "RGGB"), ([1000, 600, 300, 1000], "GRBG")] {
|
||||
let px = mosaic(w, h, |cx, cy| site.map(|v| v + shade(cx, cy)));
|
||||
assert_eq!(layout(&orf(w as u32, h as u32, 16, px.clone())), name);
|
||||
for tag in unusable {
|
||||
assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name, "{tag:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_12_bit_probe_agrees_with_decode() {
|
||||
let (w, h) = (16usize, 4usize);
|
||||
let strip: Vec<u8> = (0..w * h * 3 / 2).map(|i| (i * 37 % 251) as u8).collect();
|
||||
for tag in [Some(BGGR), None] {
|
||||
let bytes = orf_with(w as u32, h as u32, 12, strip.clone(), tag);
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!((r.bits, r.data.len()), (12, w * h));
|
||||
assert_eq!(r.cfa.as_ref().unwrap().name(), if tag.is_some() { "BGGR" } else { "RGGB" });
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), r.info());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_fallback_looks_past_fine_texture() {
|
||||
let (w, h) = (160usize, 144usize);
|
||||
// greens on the main diagonal, red and blue close; a checkerboard of detail makes the two greens of a cell
|
||||
// differ by far more than red differs from blue
|
||||
let detail = |cx: usize, cy: usize| if (cx + cy) & 1 == 0 { 150 } else { -150i32 };
|
||||
let site = |cx: usize, cy: usize| {
|
||||
let (g, d) = (1000 + shade(cx, cy) as i32, detail(cx, cy));
|
||||
[(g + d) as u16, 500, 520, (g - d) as u16]
|
||||
};
|
||||
assert_eq!(layout(&orf(w as u32, h as u32, 16, mosaic(w, h, site))), "GRBG");
|
||||
// the same scene with the greens on the other diagonal
|
||||
let px = mosaic(w, h, |cx, cy| {
|
||||
let [g0, r, b, g1] = site(cx, cy);
|
||||
[r, g0, g1, b]
|
||||
});
|
||||
assert_eq!(layout(&orf(w as u32, h as u32, 16, px)), "RGGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_fallback_survives_any_area() {
|
||||
let d = vec![100u16; 64 * 64];
|
||||
let big = usize::MAX;
|
||||
for a in [
|
||||
Rect::new(0, 0, 64, 64),
|
||||
Rect::new(0, 0, 0, 0),
|
||||
Rect::new(0, 0, 31, 31),
|
||||
Rect::new(63, 63, 1, 1),
|
||||
Rect::new(0, 0, 4096, 4096),
|
||||
Rect::new(big, big, big, big),
|
||||
Rect::new(0, big - 40, 64, 40),
|
||||
] {
|
||||
for w in [64, 0, 1, big] {
|
||||
assert_eq!(cfa_from_data(&d, w, a).name(), "RGGB", "{a:?} width {w}");
|
||||
assert_eq!(cfa_from_data(&[], w, a).name(), "RGGB", "{a:?} width {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word16_shifted_and_packed12() {
|
||||
let (w, h) = (16usize, 4usize);
|
||||
|
||||
Vendored
+239
-5
@@ -43,7 +43,9 @@
|
||||
//! reads as repeats of one code only in this order; MSB-first reading gives code frequencies of exactly
|
||||
//! 2^−length, the signature of random bits]. Tag `0x0040` lists (length, code) for 17 prefix codes, the difference
|
||||
//! categories 0–16 of ITU-T T.81 (category `c`, then `c` additional bits, a leading 0 meaning negative). Pixels
|
||||
//! are coded two rows at a time, cell by 2×2 cell (top-left, top-right, bottom-left, bottom-right); each is
|
||||
//! are coded two rows at a time, cell by 2×2 cell, column by column (top-left, bottom-left, top-right,
|
||||
//! bottom-right) [in row order the two greens trade sites: an edge's even and odd rows of green then sit ~2 px
|
||||
//! apart with red and blue between them]; each is
|
||||
//! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the
|
||||
//! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a
|
||||
//! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same
|
||||
@@ -67,12 +69,38 @@
|
||||
//! - Active area: the sensor borders. Default crop: `0x002f`–`0x0032`, the in-camera aspect ratio (e.g. 4:3 or 1:1
|
||||
//! on a 3:2 sensor; the embedded JPEG always shows the whole sensor). Some bodies write zeros there: ignored.
|
||||
//!
|
||||
//! Lens distortion (tag `0x0119`, ExifTool name DistortionInfo; ExifTool's names for its words were the only outside
|
||||
//! input, their meaning below is our own measurement). The tag holds 16 little-endian 16-bit words. The camera's
|
||||
//! own correction, which its embedded JPEG shows, is applied as an `OpcodeList3` `WarpRectilinear` (the DNG form
|
||||
//! LightCraft already applies under "Enable Profile Corrections"):
|
||||
//!
|
||||
//! - Word 7, low 4 bits: 1 = correction on [0 on the DMC-GH1, whose JPEG matches the uncorrected render].
|
||||
//! - Word 12: the normalisation radius in pixels, the half diagonal of the active area [2870 for 4592 × 3448
|
||||
//! (2871.2), 3240 for 5184 × 3888, 3611 for 5776 × 4336, 3288 for the FZ1000 II's whole 5472 × 3648 sensor
|
||||
//! even in its 4:3 mode].
|
||||
//! - Words 8, 4 and 11 (ExifTool: DistortionParam08, 04, 11), divided by 32768, are `k2`, `k4`, `k6` of the
|
||||
//! correction: a sensor point at radius ρ (in units of word 12, about the centre of the active area) appears in
|
||||
//! the corrected image at ρ·(1 + k2 ρ² + k4 ρ⁴ + k6 ρ⁶).
|
||||
//! - Word 5 (ExifTool: DistortionScale), divided by 32768, plus 1, is the zoom `z` the camera crops by afterwards:
|
||||
//! output radius = corrected radius / z [z < 1 when the corrected corners would otherwise be empty].
|
||||
//!
|
||||
//! [These rules were chosen among alternative word orders, powers, directions and readings of the zoom by fitting
|
||||
//! radial curves between our uncorrected renders and the embedded JPEGs of 38 DMC-GX85 photos (12–32 mm and 25 mm
|
||||
//! lenses). With no free parameter the words then predict the curves of 35 held-out photos from 12 other bodies and
|
||||
//! 9 other lenses (9.1–105 mm: two compacts, Panasonic, Leica, Olympus, Sigma and Samyang lenses) within 0.12 % of
|
||||
//! the half diagonal, against up to 13 % uncorrected; corrected renders of all 79 photos line up with the camera
|
||||
//! JPEG within 0.75 px at 1440 px at every corner measured.] Words 2, 3, 6, 9, 10
|
||||
//! and 13 are not used (word 13 is always word 9 + 500; word 10 is fixed per body); words 0, 1, 14 and 15 change
|
||||
//! from shot to shot. The correction is radial, so it is fitted by the DNG form's odd polynomial `u·(kr0 + kr1 u² +
|
||||
//! kr2 u⁴ + kr3 u⁶)` over the output (within 0.4 px on a 20 MP sensor); implausible values (a radius far from the
|
||||
//! active area's, a zoom beyond ±25 %, a correction that folds over) leave the photo uncorrected.
|
||||
//!
|
||||
//! Not supported ([`RawError::Unsupported`], the embedded preview is shown instead): compression 34826, raw formats
|
||||
//! 1 and 3, and bit depths other than the ones above (no samples of any of them).
|
||||
|
||||
use super::pef::{Bits, Huffman, diff};
|
||||
use super::white_from_data;
|
||||
use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, Opcode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t};
|
||||
use rayon::prelude::*;
|
||||
@@ -113,6 +141,14 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [
|
||||
(0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
(0x0043, &[1]),
|
||||
];
|
||||
/// The camera's lens distortion correction (ExifTool name: DistortionInfo); see the module docs.
|
||||
const DISTORTION_INFO: u16 = 0x0119;
|
||||
/// Fixed-point one of the distortion coefficients and zoom.
|
||||
const DISTORTION_ONE: f64 = 32768.0;
|
||||
/// Output radii sampled to fit the inverse mapping.
|
||||
const DISTORTION_SAMPLES: usize = 64;
|
||||
/// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4.
|
||||
const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3;
|
||||
/// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode).
|
||||
const MAX_STRIPS: usize = 64;
|
||||
|
||||
@@ -401,7 +437,7 @@ fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result
|
||||
for (k, v) in cell.iter_mut().enumerate() {
|
||||
let d = diff(&mut bits, codes).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: invalid code in row {}", 2 * pair)))?;
|
||||
*v = v.saturating_add(d);
|
||||
if let Some(px) = rows.get_mut(2 * pair + k / 2).and_then(|r| r.get_mut(2 * cx + k % 2)) {
|
||||
if let Some(px) = rows.get_mut(2 * pair + k % 2).and_then(|r| r.get_mut(2 * cx + k / 2)) {
|
||||
*px = (*v).clamp(0, u16::MAX as i32) as u16;
|
||||
}
|
||||
}
|
||||
@@ -586,13 +622,121 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: wb,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() },
|
||||
metadata,
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The camera's distortion correction (tag `0x0119`, see the module docs) as the equivalent DNG `WarpRectilinear`
|
||||
/// opcode for `OpcodeList3` (relative to the active area), so it reaches the same "profile corrections" as a DNG's
|
||||
/// embedded warp. `None` when the camera recorded the correction as off, when it is the identity, or when the
|
||||
/// values are implausible (the photo is then shown uncorrected rather than wrongly warped).
|
||||
fn distortion(ifd: &Ifd, active: Rect) -> Option<Opcode> {
|
||||
let b = ifd.bytes(DISTORTION_INFO)?;
|
||||
let word = |i: usize| -> Option<[u8; 2]> { b.get(2 * i..2 * i + 2)?.try_into().ok() };
|
||||
let signed = |i: usize| word(i).map(|w| i16::from_le_bytes(w) as f64);
|
||||
if u16::from_le_bytes(word(7)?) & 0x000f != 1 {
|
||||
return None;
|
||||
}
|
||||
let (k2, k4, k6) = (signed(8)? / DISTORTION_ONE, signed(4)? / DISTORTION_ONE, signed(11)? / DISTORTION_ONE);
|
||||
let zoom = 1.0 + signed(5)? / DISTORTION_ONE;
|
||||
let radius = u16::from_le_bytes(word(12)?) as f64;
|
||||
if k2 == 0.0 && k4 == 0.0 && k6 == 0.0 && zoom == 1.0 {
|
||||
return None;
|
||||
}
|
||||
// DNG's normalisation: the distance from the centre to the farthest corner, in pixel-index units
|
||||
let half_diag = (active.width as f64 - 1.0).hypot(active.height as f64 - 1.0) / 2.0;
|
||||
if !(half_diag >= 1.0 && (0.5 * half_diag..=2.0 * half_diag).contains(&radius) && (0.75..=1.25).contains(&zoom)) {
|
||||
return None;
|
||||
}
|
||||
// the camera's correction: a source point at radius ρ (in units of `radius`) moves to ρ·g(ρ)
|
||||
let corrected = |r: f64| {
|
||||
let r2 = r * r;
|
||||
r * (1.0 + r2 * (k2 + r2 * (k4 + r2 * k6)))
|
||||
};
|
||||
// the output corners show corrected radius `t_max`; the map must rise monotonically to it (a fold is no lens)
|
||||
let t_max = half_diag * zoom / radius;
|
||||
let mut top = None;
|
||||
let mut prev = 0.0;
|
||||
for i in 1..=4096 {
|
||||
let r = 3.0 * t_max * i as f64 / 4096.0;
|
||||
let v = corrected(r);
|
||||
if v.is_nan() || v <= prev {
|
||||
return None;
|
||||
}
|
||||
prev = v;
|
||||
if v >= t_max {
|
||||
top = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let top = top?;
|
||||
let source = |t: f64| {
|
||||
let (mut lo, mut hi) = (0.0, top);
|
||||
for _ in 0..60 {
|
||||
let m = 0.5 * (lo + hi);
|
||||
if corrected(m) < t { lo = m } else { hi = m }
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
};
|
||||
// least-squares fit of source radius = u·(kr0 + kr1·u² + kr2·u⁴ + kr3·u⁶) over the output radii u ∈ [0, 1]
|
||||
let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES)
|
||||
.map(|i| {
|
||||
let u = i as f64 / DISTORTION_SAMPLES as f64;
|
||||
(u, source(u * t_max) * radius / half_diag)
|
||||
})
|
||||
.collect();
|
||||
let basis = |u: f64| {
|
||||
let u2 = u * u;
|
||||
[u, u * u2, u * u2 * u2, u * u2 * u2 * u2]
|
||||
};
|
||||
let mut a = [[0.0; 4]; 4];
|
||||
let mut rhs = [0.0; 4];
|
||||
for &(u, v) in &samples {
|
||||
let f = basis(u);
|
||||
for (i, fi) in f.iter().enumerate() {
|
||||
for (j, fj) in f.iter().enumerate() {
|
||||
a[i][j] += fi * fj;
|
||||
}
|
||||
rhs[i] += fi * v;
|
||||
}
|
||||
}
|
||||
let k = solve4(a, rhs)?;
|
||||
let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>();
|
||||
let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max);
|
||||
if worst.is_nan() || worst > DISTORTION_MAX_FIT_ERROR {
|
||||
return None;
|
||||
}
|
||||
Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] })
|
||||
}
|
||||
|
||||
/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite.
|
||||
fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> {
|
||||
for c in 0..4 {
|
||||
let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, p);
|
||||
b.swap(c, p);
|
||||
for r in c + 1..4 {
|
||||
let f = a[r][c] / a[c][c];
|
||||
for k in c..4 {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
}
|
||||
}
|
||||
let mut x = [0.0; 4];
|
||||
for c in (0..4).rev() {
|
||||
let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum();
|
||||
x[c] = (b[c] - s) / a[c][c];
|
||||
}
|
||||
x.iter().all(|v| v.is_finite()).then_some(x)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -853,6 +997,76 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag-0x0119 block: 16 little-endian words, the given ones set.
|
||||
fn distortion_block(words: &[(usize, i16)]) -> Value {
|
||||
let mut w = [0i16; 16];
|
||||
for &(i, v) in words {
|
||||
w[i] = v;
|
||||
}
|
||||
Value::Undefined(w.iter().flat_map(|v| v.to_le_bytes()).collect())
|
||||
}
|
||||
|
||||
fn distorted(info: Option<Value>) -> Vec<Opcode> {
|
||||
let (w, h) = (90usize, 60usize);
|
||||
let px: Vec<u16> = (0..w * h).map(|i| (i % 4000) as u16 + 1).collect();
|
||||
let mut tags = vec![short(CFA_PATTERN, 1), short(BITS, 12), short(RAW_FORMAT, 5)];
|
||||
tags.extend(info.map(|v| (DISTORTION_INFO, v)));
|
||||
let bytes = file(w as u16, h as u16, &tags, &rotate(pack(&px, 12)), &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]);
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap().opcodes, r.opcodes, "headers-only probe reads the same correction");
|
||||
r.opcodes.list3
|
||||
}
|
||||
|
||||
/// Known answer: correction k2 = 4096/32768 = 1/8 (word 8), zoom 1 − 1024/32768 (word 5), radius 51 px (word 12)
|
||||
/// on an 86 × 58 active area. A sensor point at ρ appears at ρ + ρ³/8, so the output corner (corrected radius
|
||||
/// t = half diagonal · zoom / 51) shows the root of ρ³ + 8ρ − 8t = 0, here by Cardano's formula.
|
||||
#[test]
|
||||
fn distortion_correction_becomes_a_rectilinear_warp() {
|
||||
let on = |more: &[(usize, i16)]| {
|
||||
let mut w = vec![(7, 0x0001), (8, 4096), (5, -1024), (12, 51)];
|
||||
w.extend_from_slice(more);
|
||||
distortion_block(&w)
|
||||
};
|
||||
let ops = distorted(Some(on(&[])));
|
||||
let [Opcode::WarpRectilinear { planes, center }] = ops.as_slice() else { panic!("{ops:?}") };
|
||||
assert_eq!((planes.len(), *center), (1, [0.5, 0.5]));
|
||||
let k = planes[0];
|
||||
assert_eq!((k[4], k[5]), (0.0, 0.0), "radial only");
|
||||
let half_diag = 85f64.hypot(57.0) / 2.0;
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 1.0] {
|
||||
let t = u * half_diag * (1.0 - 1024.0 / 32768.0) / 51.0;
|
||||
let (p, q) = (8.0, -8.0 * t);
|
||||
let d = (q * q / 4.0 + p * p * p / 27.0).sqrt();
|
||||
let rho = (-q / 2.0 + d).cbrt() + (-q / 2.0 - d).cbrt();
|
||||
let want = rho * 51.0 / half_diag;
|
||||
let got = u * (k[0] + k[1] * u * u + k[2] * u.powi(4) + k[3] * u.powi(6));
|
||||
// the 4-term odd polynomial approximates the exact inverse (5e-5 of the half diagonal ≈ 0.2 px at 20 MP)
|
||||
assert!((got - want).abs() < 5e-5, "u {u}: source radius {got}, want {want}");
|
||||
}
|
||||
|
||||
// no correction: no block, recorded as off, or the identity
|
||||
assert!(distorted(None).is_empty());
|
||||
assert!(distorted(Some(distortion_block(&[(7, 0x0000), (8, 4096), (12, 51)]))).is_empty(), "off");
|
||||
assert!(distorted(Some(distortion_block(&[(7, 0x7f00u16 as i16), (8, 4096), (12, 51)]))).is_empty(), "off, other bits set");
|
||||
assert!(distorted(Some(distortion_block(&[(7, 0x0001), (12, 51)]))).is_empty(), "identity");
|
||||
// hostile values are ignored, never a panic or a wild warp
|
||||
for (what, block) in [
|
||||
("truncated", Value::Undefined(vec![1, 0, 2, 0, 3])),
|
||||
("radius 0", on(&[(12, 0)])),
|
||||
("radius far beyond the sensor", on(&[(12, -1)])),
|
||||
("zoom 0", on(&[(5, -32768)])),
|
||||
("folds over", on(&[(8, -32768)])),
|
||||
("folds over at high order", on(&[(11, -32768), (4, -32768)])),
|
||||
("runs away", on(&[(8, 32767), (4, 32767), (11, 32767)])),
|
||||
] {
|
||||
let ops = distorted(Some(block));
|
||||
assert!(ops.iter().all(|op| !matches!(op, Opcode::WarpRectilinear { .. })) || what == "runs away", "{what}: {ops:?}");
|
||||
if let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() {
|
||||
assert!(planes[0].iter().all(|c| c.is_finite()), "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_and_word_formats() {
|
||||
let (w, h) = (90usize, 200usize); // > one chunk of data
|
||||
@@ -962,7 +1176,7 @@ mod tests {
|
||||
let mut cell = first;
|
||||
for cx in 0..w / 2 {
|
||||
for (k, p) in cell.iter_mut().enumerate() {
|
||||
let v = at(2 * cx + k % 2, 2 * pair + k / 2);
|
||||
let v = at(2 * cx + k / 2, 2 * pair + k % 2);
|
||||
let d = v - *p;
|
||||
let c = 32 - d.unsigned_abs().leading_zeros();
|
||||
let (len, code) = table[c as usize];
|
||||
@@ -1026,6 +1240,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format8_cell_is_coded_column_by_column() {
|
||||
// One 2×2 cell coded by hand (not with `encode_strip`, which shares the decoder's site order): the values
|
||||
// 1, 2, 3, 4 in stream order. Row order would put the two greens of an RGGB cell on each other's sites.
|
||||
let mut bits: Vec<bool> = vec![];
|
||||
let mut put = |n: u32, v: u32| bits.extend((0..n).rev().map(|i| v >> i & 1 == 1));
|
||||
for v in [1u32, 2, 3, 4] {
|
||||
let c = 32 - v.leading_zeros();
|
||||
let (len, code) = TABLE14[c as usize];
|
||||
put(len, code);
|
||||
put(c, v);
|
||||
}
|
||||
let n = bits.len() as u64;
|
||||
let mut data: Vec<u8> = bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect();
|
||||
data.extend([0; 8]);
|
||||
let (mut top, mut bottom) = ([0u16; 2], [0u16; 2]);
|
||||
decode_strip(&Strip { data: &data, width: 2, height: 2, bits: n }, &code_table(&TABLE14).unwrap(), vec![&mut top, &mut bottom]).unwrap();
|
||||
assert_eq!((top, bottom), ([1, 3], [2, 4]), "top-left, bottom-left, top-right, bottom-right");
|
||||
}
|
||||
|
||||
fn replace(tags: &mut Vec<(u16, Value)>, tag: u16, v: Value) {
|
||||
tags.retain(|(t, _)| *t != tag);
|
||||
tags.push((tag, v));
|
||||
|
||||
@@ -427,3 +427,32 @@ fn corpus_panasonic_encodings() {
|
||||
}
|
||||
eprintln!("Panasonic encodings checked on {seen} files");
|
||||
}
|
||||
|
||||
/// Olympus ORF (`crates/raw/src/vendor/orf.rs`): the colour-filter layout comes from the file's Exif `CFAPattern`.
|
||||
/// With it, the decoded mosaic's colours follow the camera's own JPEG better than with any other layout.
|
||||
#[test]
|
||||
fn corpus_orf_cfa_patterns() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let cases = [("orf-olympus-e1.orf", "GRBG"), ("orf-olympus-e400.orf", "GRBG"), ("orf-olympus-xz2.orf", "RGGB")];
|
||||
let mut seen = 0;
|
||||
for (name, cfa) in cases {
|
||||
let Ok(bytes) = std::fs::read(dir.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
};
|
||||
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout");
|
||||
assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}");
|
||||
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview"));
|
||||
let (gw, gh) = (24, 18);
|
||||
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
|
||||
let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern);
|
||||
let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max);
|
||||
let own = agree(cfa);
|
||||
let a = img.active_area;
|
||||
eprintln!("{name:32} {cfa}, active area at ({}, {}): colour agreement {own:.3} (others ≤ {best_other:.3})", a.x, a.y);
|
||||
assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})");
|
||||
seen += 1;
|
||||
}
|
||||
eprintln!("ORF CFA layouts checked on {seen} files");
|
||||
}
|
||||
@@ -51,6 +51,14 @@ pub fn mirrors(env: Option<&str>, file: Option<&Path>) -> Vec<String> {
|
||||
lightcraft_fetch::mirrors(env, file, DEFAULT_MIRRORS)
|
||||
}
|
||||
|
||||
/// Download settings for SAM 3: the defaults, plus [`TOKEN_ENV`] for a gated mirror.
|
||||
pub fn options() -> Options {
|
||||
Options { token_env: Some(TOKEN_ENV), ..Options::default() }
|
||||
}
|
||||
|
||||
/// Environment variable with a bearer token for the SAM 3 mirror's own host (a gated Hugging Face repository).
|
||||
pub const TOKEN_ENV: &str = "LIGHTCRAFT_SAM3_TOKEN";
|
||||
|
||||
/// What to tell the user when there is no mirror for the SAM 3 model.
|
||||
pub fn no_mirrors_message() -> String {
|
||||
format!(
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
" · {} failed: {}": "・{}件失敗:{}",
|
||||
" · {} moved": "・{}件移動",
|
||||
" · {} not readable": "・{}件は読み込み不可",
|
||||
" · {} restored from Recently Deleted": "・最近削除した写真から{}枚を復元",
|
||||
", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}",
|
||||
"Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加",
|
||||
"Add {} Photo{}": "{}枚の写真を追加{:.0}",
|
||||
"Add {} Photo{}, restore {}": "{}枚の写真を追加{:.0}、{}枚を復元",
|
||||
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
|
||||
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
|
||||
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
|
||||
@@ -53,6 +55,7 @@
|
||||
"Rename “{name}”": "「{name}」の名前を変更",
|
||||
"Rendering on the CPU: {why}": "CPUで描画:{why}",
|
||||
"Reset {} Channel": "{}チャンネルをリセット",
|
||||
"Restore {} Photo{}": "{}枚の写真を復元{:.0}",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
|
||||
"Showing {n} of {}": "{}枚中{n}枚を表示",
|
||||
@@ -118,5 +121,6 @@
|
||||
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
|
||||
"{weekday}, {day}": "{day}日({weekday})",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
|
||||
"{year}": "{year}年"
|
||||
"{year}": "{year}年",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.0}"
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
"Amount": "適用量",
|
||||
"Aperture": "絞り",
|
||||
"Application": "アプリケーション",
|
||||
"Apply": "適用",
|
||||
"Apply Preset": "プリセットを適用",
|
||||
"Approved": "承認済み",
|
||||
"Aqua": "アクア",
|
||||
@@ -147,6 +148,7 @@
|
||||
"Cloudy": "曇り",
|
||||
"Collapse All Stacks": "すべてのスタックを閉じる",
|
||||
"Color": "カラー",
|
||||
"Color label cleared": "カラーラベルを解除しました",
|
||||
"Color Grading": "カラーグレーディング",
|
||||
"Color Mixer": "カラーミキサー",
|
||||
"Color Noise Reduction": "カラーノイズ軽減",
|
||||
@@ -257,6 +259,7 @@
|
||||
"Edit in External Editor": "外部エディターで編集",
|
||||
"Edited": "編集済み",
|
||||
"Effects": "効果",
|
||||
"Empty Recently Deleted": "最近削除した写真を空にする",
|
||||
"Enable Lens Corrections": "レンズ補正を有効にする",
|
||||
"Enter Full Screen": "全画面表示",
|
||||
"Erase": "消去",
|
||||
@@ -287,6 +290,9 @@
|
||||
"File Name": "ファイル名",
|
||||
"File Path": "ファイルの場所",
|
||||
"File Size": "ファイルサイズ",
|
||||
"Import as new": "新規として読み込む",
|
||||
"Lock": "ロック",
|
||||
"Locked": "ロック中",
|
||||
"Random": "ランダム",
|
||||
"Reshuffle": "シャッフルし直す",
|
||||
"File name": "ファイル名",
|
||||
@@ -717,6 +723,8 @@
|
||||
"Resize to": "サイズ変更",
|
||||
"Resolution (ppi)": "解像度(ppi)",
|
||||
"Restore": "復元",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "復元すると編集内容が残ります。新規として読み込むと、先に古い記録を削除します。",
|
||||
"Restore them": "復元する",
|
||||
"Retouch": "レタッチ",
|
||||
"Review Import": "読み込み内容を確認",
|
||||
"Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "写真を右クリックして参照写真に設定し、「表示 → 参照表示」(⇧R)を選択してください",
|
||||
@@ -760,6 +768,7 @@
|
||||
"Set Color Label": "カラーラベルを設定",
|
||||
"Set Develop Values": "現像設定を変更",
|
||||
"Set Flag": "フラグを設定",
|
||||
"Set Image Zoom and Pan": "画像のズームとパンを設定",
|
||||
"Set Point Curve": "ポイントカーブを変更",
|
||||
"Set Profile": "プロファイルを設定",
|
||||
"Set Rating": "評価を設定",
|
||||
@@ -803,6 +812,8 @@
|
||||
"Show destination gamut warning": "出力先の色域警告を表示",
|
||||
"Show display gamut warning": "ディスプレイの色域警告を表示",
|
||||
"Show file names": "ファイル名を表示",
|
||||
"Show in Explorer": "エクスプローラーで表示",
|
||||
"Show in File Manager": "ファイルマネージャーで表示",
|
||||
"Show in Finder": "Finderで表示",
|
||||
"Show on Map": "地図で表示",
|
||||
"Show overlay (O)": "重ね表示(O)",
|
||||
@@ -932,6 +943,8 @@
|
||||
"Width (% of photo)": "幅(写真に対する%)",
|
||||
"Width (px)": "幅(px)",
|
||||
"Width × Height": "幅 × 高さ",
|
||||
"Will be replaced": "置き換えられます",
|
||||
"Will be restored": "復元されます",
|
||||
"Window": "ウィンドウ",
|
||||
"XMP sidecars": "XMPサイドカーファイル",
|
||||
"Year": "年",
|
||||
@@ -1029,5 +1042,18 @@
|
||||
"Friday": "金曜日",
|
||||
"Saturday": "土曜日",
|
||||
"Sunday": "日曜日",
|
||||
"Unknown Date": "日付不明"
|
||||
"Unknown Date": "日付不明",
|
||||
"Remove Folder from Library": "フォルダをライブラリから除去",
|
||||
"Remove from Library…": "ライブラリから除去…",
|
||||
"This Computer": "このコンピュータ",
|
||||
"includes the folders inside it": "内側のフォルダを含む",
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "「最近削除した写真」に移動し、復元できます。ディスク上のファイルには手を加えません",
|
||||
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "このフォルダから読み込んだ写真を「最近削除した写真」に移動します。ファイルには手を加えません",
|
||||
"Remove Disk from Library…": "ディスクをライブラリから除去…",
|
||||
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "このディスクから読み込んだすべての写真を「最近削除した写真」に移動します。ファイルには手を加えません",
|
||||
"Collapse": "折りたたむ",
|
||||
"Expand": "展開",
|
||||
"Remove Disk from Library": "ディスクをライブラリから除去",
|
||||
"All the photos imported from this disk are included.": "このディスクから読み込んだすべての写真が対象です",
|
||||
"That includes the photos in the folders inside it.": "内側のフォルダにある写真も含まれます"
|
||||
}
|
||||
@@ -5,9 +5,11 @@
|
||||
" · {} failed: {}": " · {} falhou: {}",
|
||||
" · {} moved": " · {} movida(s)",
|
||||
" · {} not readable": " · {} não legível",
|
||||
" · {} restored from Recently Deleted": " · {} restaurada(s) de Excluídos recentemente",
|
||||
", {failed} file{} not readable": ", {failed} arquivo{:.0} não legível",
|
||||
"Add {local_n} to My Photos": "Adicionar {local_n} a Minhas fotos",
|
||||
"Add {} Photo{}": "Adicionar {} foto{:.0}",
|
||||
"Add {} Photo{}, restore {}": "Adicionar {} foto{:.0}, restaurar {}",
|
||||
"Add “{}” to Favorites": "Adicionar “{}” aos favoritos",
|
||||
"Added {n} photo{} to My Photos": "Adicionada {n} foto{:.0} a Minhas fotos",
|
||||
"Added {n} photo{} to “{}”": "Adicionada {n} foto{:.0} a “{}”",
|
||||
@@ -61,12 +63,14 @@
|
||||
"Reading photos… {done} of {total}": "Lendo fotos… {done} de {total}",
|
||||
"Reading photos… {} of {}": "Lendo fotos… {} de {}",
|
||||
"Redo {}": "Refazer {}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "Remover “{name}” e {count} foto{} da biblioteca?",
|
||||
"Remove “{}” from Favorites": "Remover “{}” dos favoritos",
|
||||
"Rename {n} Photos…": "Renomear {n} fotos…",
|
||||
"Rename “{name}”": "Renomear “{name}”",
|
||||
"Rendering on the CPU: {why}": "Renderizando na CPU: {why}",
|
||||
"Reset {name}": "Redefinir {name}",
|
||||
"Reset {} Channel": "Redefinir canal {}",
|
||||
"Restore {} Photo{}": "Restaurar {} foto{:.0}",
|
||||
"Saved export preset “{}”": "Predefinição de exportação “{}” salva",
|
||||
"Show {hidden} hidden location{}": "Mostrar {hidden} local{:.0} oculto",
|
||||
"Showing {n} of {}": "Mostrando {n} de {}",
|
||||
|
||||
@@ -42,12 +42,14 @@
|
||||
"All Metadata…": "Todos os metadados…",
|
||||
"All Photos": "Todas as fotos",
|
||||
"All rights reserved": "Todos os direitos reservados",
|
||||
"All the photos imported from this disk are included.": "Todas as fotos importadas deste disco estão incluídas.",
|
||||
"All types": "Todos os tipos",
|
||||
"Alt Text": "Texto alternativo",
|
||||
"Always": "Sempre",
|
||||
"Amount": "Quantidade",
|
||||
"Aperture": "Abertura",
|
||||
"Application": "Aplicativo",
|
||||
"Apply": "Aplicar",
|
||||
"Apply Color Label Set": "Aplicar conjunto de rótulos de cor",
|
||||
"Apply Filter Preset": "Aplicar predefinição de filtro",
|
||||
"Apply Metadata Preset": "Aplicar predefinição de metadados",
|
||||
@@ -188,6 +190,7 @@
|
||||
"Close all": "Fechar tudo",
|
||||
"Close file": "Fechar arquivo",
|
||||
"Cloudy": "Nublado",
|
||||
"Collapse": "Recolher",
|
||||
"Collapse All Stacks": "Recolher todas as pilhas",
|
||||
"Color": "Cor",
|
||||
"Color Grading": "Graduação de cores",
|
||||
@@ -197,6 +200,7 @@
|
||||
"Color Noise Reduction": "Redução de ruído de cor",
|
||||
"Color Priority": "Prioridade de cor",
|
||||
"Color Range": "Intervalo de cor",
|
||||
"Color label cleared": "Rótulo de cor removido",
|
||||
"Color space": "Espaço de cor",
|
||||
"Color space: sRGB (AVIF)": "Espaço de cor: sRGB (AVIF)",
|
||||
"Combine files…": "Combinar arquivos…",
|
||||
@@ -353,6 +357,7 @@
|
||||
"Edited": "Editada",
|
||||
"Effect": "Efeito",
|
||||
"Effects": "Efeitos",
|
||||
"Empty Recently Deleted": "Esvaziar Excluídos recentemente",
|
||||
"Enable Lens Corrections": "Ativar correções de lente",
|
||||
"End Interaction": "Encerrar interação",
|
||||
"English": "Inglês",
|
||||
@@ -361,6 +366,7 @@
|
||||
"Every EXIF, GPS and XMP field in the file": "Todos os campos EXIF, GPS e XMP do arquivo",
|
||||
"Existing previews": "Prévias existentes",
|
||||
"Exit": "Sair",
|
||||
"Expand": "Expandir",
|
||||
"Expand All Stacks": "Expandir todas as pilhas",
|
||||
"Expand/Collapse Stack": "Expandir/recolher pilha",
|
||||
"Export": "Exportar",
|
||||
@@ -523,6 +529,7 @@
|
||||
"Import Presets…": "Importar predefinições…",
|
||||
"Import Profiles": "Importar perfis",
|
||||
"Import Profiles & Presets…": "Importar perfis e predefinições…",
|
||||
"Import as new": "Importar como novas",
|
||||
"Import from Device": "Importar de dispositivo",
|
||||
"Import from Folder…": "Importar de pasta…",
|
||||
"Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importe fotos com Arquivo → Importar fotos… (Cmd+Shift+I) ou solte-as aqui",
|
||||
@@ -610,6 +617,8 @@
|
||||
"Locate Missing File…": "Localizar arquivo ausente…",
|
||||
"Locate Photo": "Localizar foto",
|
||||
"Location": "Local",
|
||||
"Lock": "Travar",
|
||||
"Locked": "Travada",
|
||||
"Long Edge": "Aresta longa",
|
||||
"Looking for photos…": "Procurando fotos…",
|
||||
"Lossless": "Sem perdas",
|
||||
@@ -681,6 +690,8 @@
|
||||
"Move to": "Mover para",
|
||||
"Move to Group": "Mover para grupo",
|
||||
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: os arquivos são movidos para a pasta abaixo; cada original (e seu sidecar XMP) é removido da origem somente após a cópia ser verificada. Duplicatas e arquivos que falham permanecem onde estão.",
|
||||
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Move todas as fotos importadas deste disco para Excluídos recentemente; nenhum arquivo é alterado",
|
||||
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Move as fotos importadas desta pasta para Excluídos recentemente; nenhum arquivo é alterado",
|
||||
"Muted": "Suave",
|
||||
"Muted Film": "Filme suave",
|
||||
"My Photos": "Minhas fotos",
|
||||
@@ -900,8 +911,12 @@
|
||||
"Remove (H)": "Remover (H)",
|
||||
"Remove CA": "Remover AC",
|
||||
"Remove Chromatic Aberration": "Remover aberração cromática",
|
||||
"Remove Disk from Library": "Remover disco da biblioteca",
|
||||
"Remove Disk from Library…": "Remover disco da biblioteca…",
|
||||
"Remove Folder from Library": "Remover pasta da biblioteca",
|
||||
"Remove from Album": "Remover do álbum",
|
||||
"Remove from Favorites": "Remover dos favoritos",
|
||||
"Remove from Library…": "Remover da biblioteca…",
|
||||
"Remove from Local": "Remover de Local",
|
||||
"Remove from Photo": "Remover da foto",
|
||||
"Remove from Selected Photos": "Remover das fotos selecionadas",
|
||||
@@ -943,6 +958,8 @@
|
||||
"Restore": "Restaurar",
|
||||
"Restore Library from Backup…": "Restaurar biblioteca do backup…",
|
||||
"Restore Version": "Restaurar versão",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "Restaurar mantém as edições; Importar como novas apaga antes o registro antigo.",
|
||||
"Restore them": "Restaurá-las",
|
||||
"Retouch": "Retoque",
|
||||
"Revert": "Reverter",
|
||||
"Review Import": "Revisar importação",
|
||||
@@ -1009,6 +1026,7 @@
|
||||
"Set Crop": "Definir recorte",
|
||||
"Set Develop Values": "Definir valores de revelação",
|
||||
"Set Flag": "Definir marcação",
|
||||
"Set Image Zoom and Pan": "Definir zoom e posição da imagem",
|
||||
"Set Mask Adjustments": "Definir ajustes da máscara",
|
||||
"Set Point Curve": "Definir curva de pontos",
|
||||
"Set Profile": "Definir perfil",
|
||||
@@ -1053,6 +1071,8 @@
|
||||
"Show destination gamut warning": "Mostrar aviso de gamut de destino",
|
||||
"Show display gamut warning": "Mostrar aviso de gamut da tela",
|
||||
"Show file names": "Mostrar nomes dos arquivos",
|
||||
"Show in Explorer": "Mostrar no Explorador de Arquivos",
|
||||
"Show in File Manager": "Mostrar no gerenciador de arquivos",
|
||||
"Show in Finder": "Mostrar no Finder",
|
||||
"Show on Map": "Mostrar no mapa",
|
||||
"Show overlay (O)": "Mostrar sobreposição (O)",
|
||||
@@ -1141,13 +1161,16 @@
|
||||
"Text": "Texto",
|
||||
"Text direction": "Direção do texto",
|
||||
"Texture": "Textura",
|
||||
"That includes the photos in the folders inside it.": "Isso inclui as fotos das pastas dentro dela.",
|
||||
"The SAM 3 model is installed: Object and Describe masks are ready.": "O modelo SAM 3 está instalado: as máscaras Objeto e Descrever estão prontas.",
|
||||
"The download didn't work:": "O download não funcionou:",
|
||||
"The original files, unchanged, each with an XMP sidecar holding its edits.": "Os arquivos originais, inalterados, cada um com um sidecar XMP contendo suas edições.",
|
||||
"The photo has these settings": "A foto tem estas configurações",
|
||||
"Theme": "Tema",
|
||||
"They can be restored from Recently Deleted until it is emptied.": "Elas podem ser restauradas em Excluídos recentemente até que ele seja esvaziado.",
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "Elas vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.",
|
||||
"Thirds": "Terços",
|
||||
"This Computer": "Este computador",
|
||||
"This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Esta pasta não está disponível (unidade desconectada?). As pré-visualizações inteligentes não são geradas nem usadas até ela voltar ou você escolher outra pasta.",
|
||||
"This photo can't be opened": "Não foi possível abrir esta foto",
|
||||
"Threshold": "Limite",
|
||||
@@ -1231,6 +1254,8 @@
|
||||
"Width (% of photo)": "Largura (% da foto)",
|
||||
"Width (px)": "Largura (px)",
|
||||
"Width × Height": "Largura × altura",
|
||||
"Will be replaced": "Será substituída",
|
||||
"Will be restored": "Será restaurada",
|
||||
"Window": "Janela",
|
||||
"Winter Blue": "Azul invernal",
|
||||
"XMP Sidecar Preferences": "Preferências de sidecar XMP",
|
||||
@@ -1251,6 +1276,7 @@
|
||||
"Zoom to page level": "Zoom no nível da página",
|
||||
"and": "e",
|
||||
"comma, separated": "separado por vírgulas",
|
||||
"includes the folders inside it": "inclui as pastas dentro dela",
|
||||
"memory only": "somente memória",
|
||||
"none": "nenhum",
|
||||
"not available here": "não disponível aqui",
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
" · {} failed: {}": " · {0} 个失败:{1}",
|
||||
" · {} moved": " · 已移动 {0} 个",
|
||||
" · {} not readable": " · {0} 个无法读取",
|
||||
" · {} restored from Recently Deleted": " · 已从“最近删除”恢复 {} 张",
|
||||
", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}",
|
||||
"Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片",
|
||||
"Add {} Photo{}": "添加 {0} 张照片{1:.0}",
|
||||
"Add {} Photo{}, restore {}": "添加 {0} 张照片{1:.0},恢复 {2} 张",
|
||||
"Add “{}” to Favorites": "将“{0}”添加到收藏夹",
|
||||
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
|
||||
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
|
||||
@@ -53,6 +55,7 @@
|
||||
"Rename “{name}”": "重命名“{name}”",
|
||||
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
|
||||
"Reset {} Channel": "重置 {0} 通道",
|
||||
"Restore {} Photo{}": "恢复 {0} 张照片{1:.0}",
|
||||
"Saved export preset “{}”": "已保存导出预设“{0}”",
|
||||
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
|
||||
"Showing {n} of {}": "显示 {0} 张中的 {n} 张",
|
||||
@@ -118,5 +121,6 @@
|
||||
"{month}": "{month}月",
|
||||
"{month} {year}": "{year}年{month}月",
|
||||
"{weekday}, {day}": "{day}日 {weekday}",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.0}"
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
"Amount": "数量",
|
||||
"Aperture": "光圈",
|
||||
"Application": "应用程序",
|
||||
"Apply": "应用",
|
||||
"Apply Color Label Set": "应用颜色标签集",
|
||||
"Apply Filter Preset": "应用筛选器预设",
|
||||
"Apply Metadata Preset": "应用元数据预设",
|
||||
@@ -187,6 +188,7 @@
|
||||
"Cloudy": "阴天",
|
||||
"Collapse All Stacks": "折叠所有堆叠",
|
||||
"Color": "颜色",
|
||||
"Color label cleared": "已清除颜色标签",
|
||||
"Color Grading": "颜色分级",
|
||||
"Color Label Names": "颜色标签名称",
|
||||
"Color Label Sets": "颜色标签集",
|
||||
@@ -348,6 +350,7 @@
|
||||
"Edited": "已编辑",
|
||||
"Effect": "效果",
|
||||
"Effects": "效果",
|
||||
"Empty Recently Deleted": "清空“最近删除”",
|
||||
"Enable Lens Corrections": "启用镜头校正",
|
||||
"End Interaction": "结束交互",
|
||||
"English": "English",
|
||||
@@ -389,6 +392,9 @@
|
||||
"File Name": "文件名",
|
||||
"File Path": "文件路径",
|
||||
"File Size": "文件大小",
|
||||
"Import as new": "作为新照片导入",
|
||||
"Lock": "锁定",
|
||||
"Locked": "已锁定",
|
||||
"Random": "随机",
|
||||
"Reshuffle": "重新随机排序",
|
||||
"File name": "文件名",
|
||||
@@ -936,6 +942,8 @@
|
||||
"Restore": "恢复",
|
||||
"Restore Library from Backup…": "从备份恢复图库…",
|
||||
"Restore Version": "恢复版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "恢复会保留编辑;作为新照片导入会先删除旧记录。",
|
||||
"Restore them": "恢复",
|
||||
"Retouch": "润饰",
|
||||
"Revert": "恢复",
|
||||
"Review Import": "查看导入内容",
|
||||
@@ -1001,6 +1009,7 @@
|
||||
"Set Crop": "设置裁切",
|
||||
"Set Develop Values": "设置编辑参数",
|
||||
"Set Flag": "设置旗标",
|
||||
"Set Image Zoom and Pan": "设置图像缩放和平移",
|
||||
"Set Mask Adjustments": "设置蒙版调整",
|
||||
"Set Point Curve": "设置点曲线",
|
||||
"Set Profile": "设置配置文件",
|
||||
@@ -1045,6 +1054,8 @@
|
||||
"Show destination gamut warning": "显示目标色域警告",
|
||||
"Show display gamut warning": "显示显示器色域警告",
|
||||
"Show file names": "显示文件名",
|
||||
"Show in Explorer": "在文件资源管理器中显示",
|
||||
"Show in File Manager": "在文件管理器中显示",
|
||||
"Show in Finder": "在 Finder 中显示",
|
||||
"Show on Map": "在地图上显示",
|
||||
"Show overlay (O)": "显示叠加 (O)",
|
||||
@@ -1220,6 +1231,8 @@
|
||||
"Width (% of photo)": "宽度(照片百分比)",
|
||||
"Width (px)": "宽度 (px)",
|
||||
"Width × Height": "宽度 × 高度",
|
||||
"Will be replaced": "将替换",
|
||||
"Will be restored": "将恢复",
|
||||
"Window": "窗口",
|
||||
"Winter Blue": "冬日蓝调",
|
||||
"XMP Sidecar Preferences": "XMP 附属文件偏好设置",
|
||||
@@ -1246,5 +1259,18 @@
|
||||
"this photo": "这张照片",
|
||||
"日本語": "日本語",
|
||||
"繁體中文(台灣)": "繁體中文(台灣)",
|
||||
"In Recently Deleted": "在“最近删除”中"
|
||||
"In Recently Deleted": "在“最近删除”中",
|
||||
"Remove Folder from Library": "从图库中移除文件夹",
|
||||
"Remove from Library…": "从图库中移除…",
|
||||
"This Computer": "此电脑",
|
||||
"includes the folders inside it": "包含其中的文件夹",
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "它们会移到“最近删除”,可以恢复;磁盘上的文件不会被改动。",
|
||||
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "将从此文件夹导入的照片移到“最近删除”;不会改动任何文件",
|
||||
"Remove Disk from Library…": "从图库中移除磁盘…",
|
||||
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "将从此磁盘导入的所有照片移到“最近删除”;不会改动任何文件",
|
||||
"Collapse": "折叠",
|
||||
"Expand": "展开",
|
||||
"Remove Disk from Library": "从图库中移除磁盘",
|
||||
"All the photos imported from this disk are included.": "包括从此磁盘导入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中文件夹里的照片也包括在内。"
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
" · {} failed: {}": " · {} 個失敗:{}",
|
||||
" · {} moved": " · 已移動 {} 個",
|
||||
" · {} not readable": " · {} 個無法讀取",
|
||||
" · {} restored from Recently Deleted": " · 已從「最近刪除」還原 {} 張",
|
||||
", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}",
|
||||
"Add {local_n} to My Photos": "將 {local_n} 張加入我的照片",
|
||||
"Add {} Photo{}": "新增 {} 張照片{:.0}",
|
||||
"Add {} Photo{}, restore {}": "新增 {} 張照片{:.0},還原 {} 張",
|
||||
"Add “{}” to Favorites": "將「{}」加入喜好項目",
|
||||
"Added {n} photo{} to My Photos": "已將 {n} 張照片加入我的照片{:.0}",
|
||||
"Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
|
||||
@@ -64,6 +66,7 @@
|
||||
"Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
|
||||
"Reset {name}": "重設{name}",
|
||||
"Reset {} Channel": "重設 {} 通道",
|
||||
"Restore {} Photo{}": "還原 {} 張照片{:.0}",
|
||||
"Saved export preset “{}”": "已儲存匯出預設集「{}」",
|
||||
"Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
|
||||
"Showing {n} of {}": "顯示 {n}/{} 張",
|
||||
@@ -118,5 +121,6 @@
|
||||
"… and {more} more": "…以及另外 {more} 個",
|
||||
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
|
||||
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入",
|
||||
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}"
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
"Amount": "強度",
|
||||
"Aperture": "光圈",
|
||||
"Application": "應用程式",
|
||||
"Apply": "套用",
|
||||
"Apply Color Label Set": "套用色彩標籤集",
|
||||
"Apply Filter Preset": "套用篩選預設集",
|
||||
"Apply Metadata Preset": "套用中繼資料預設集",
|
||||
@@ -187,6 +188,7 @@
|
||||
"Cloudy": "陰天",
|
||||
"Collapse All Stacks": "收合所有堆疊",
|
||||
"Color": "色彩",
|
||||
"Color label cleared": "已清除色彩標籤",
|
||||
"Color Grading": "色彩分級",
|
||||
"Color Label Names": "色彩標籤名稱",
|
||||
"Color Label Sets": "色彩標籤集",
|
||||
@@ -348,6 +350,7 @@
|
||||
"Edited": "已編輯",
|
||||
"Effect": "效果",
|
||||
"Effects": "效果",
|
||||
"Empty Recently Deleted": "清空「最近刪除」",
|
||||
"Enable Lens Corrections": "啟用鏡頭校正",
|
||||
"End Interaction": "結束互動",
|
||||
"English": "English",
|
||||
@@ -389,6 +392,9 @@
|
||||
"File Name": "檔案名稱",
|
||||
"File Path": "檔案路徑",
|
||||
"File Size": "檔案大小",
|
||||
"Import as new": "以新照片匯入",
|
||||
"Lock": "鎖定",
|
||||
"Locked": "已鎖定",
|
||||
"Random": "隨機",
|
||||
"Reshuffle": "重新隨機排序",
|
||||
"File name": "檔案名稱",
|
||||
@@ -936,6 +942,8 @@
|
||||
"Restore": "還原",
|
||||
"Restore Library from Backup…": "從備份還原照片庫…",
|
||||
"Restore Version": "還原版本",
|
||||
"Restore keeps their edits; Import as new deletes the old record first.": "還原會保留編輯;以新照片匯入會先刪除舊紀錄。",
|
||||
"Restore them": "還原",
|
||||
"Retouch": "修飾",
|
||||
"Revert": "回復至上次儲存",
|
||||
"Review Import": "檢視匯入",
|
||||
@@ -1001,6 +1009,7 @@
|
||||
"Set Crop": "設定裁切",
|
||||
"Set Develop Values": "設定顯像參數",
|
||||
"Set Flag": "設定旗標",
|
||||
"Set Image Zoom and Pan": "設定影像縮放與平移",
|
||||
"Set Mask Adjustments": "設定遮色片調整",
|
||||
"Set Point Curve": "設定點曲線",
|
||||
"Set Profile": "設定描述檔",
|
||||
@@ -1045,6 +1054,8 @@
|
||||
"Show destination gamut warning": "顯示目標色域警告",
|
||||
"Show display gamut warning": "顯示螢幕色域警告",
|
||||
"Show file names": "顯示檔案名稱",
|
||||
"Show in Explorer": "在檔案總管中顯示",
|
||||
"Show in File Manager": "在檔案管理員中顯示",
|
||||
"Show in Finder": "在 Finder 中顯示",
|
||||
"Show on Map": "在地圖上顯示",
|
||||
"Show overlay (O)": "顯示疊加(O)",
|
||||
@@ -1220,6 +1231,8 @@
|
||||
"Width (% of photo)": "寬度(照片百分比)",
|
||||
"Width (px)": "寬度(像素)",
|
||||
"Width × Height": "寬度 × 高度",
|
||||
"Will be replaced": "將取代",
|
||||
"Will be restored": "將還原",
|
||||
"Window": "視窗",
|
||||
"Winter Blue": "冬日藍調",
|
||||
"XMP Sidecar Preferences": "XMP 附屬檔案偏好設定",
|
||||
@@ -1246,5 +1259,18 @@
|
||||
"this photo": "這張照片",
|
||||
"日本語": "日本語",
|
||||
"繁體中文(台灣)": "繁體中文(台灣)",
|
||||
"In Recently Deleted": "在「最近刪除」中"
|
||||
"In Recently Deleted": "在「最近刪除」中",
|
||||
"Remove Folder from Library": "從照片庫移除資料夾",
|
||||
"Remove from Library…": "從照片庫移除…",
|
||||
"This Computer": "這部電腦",
|
||||
"includes the folders inside it": "包含其中的資料夾",
|
||||
"They move to Recently Deleted and can be restored; no file on disk is touched.": "它們會移至「最近刪除」,可以還原;磁碟上的檔案不會被更動。",
|
||||
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "將從此資料夾匯入的照片移至「最近刪除」;不會更動任何檔案",
|
||||
"Remove Disk from Library…": "從照片庫移除磁碟…",
|
||||
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "將從此磁碟匯入的所有照片移至「最近刪除」;不會更動任何檔案",
|
||||
"Collapse": "收合",
|
||||
"Expand": "展開",
|
||||
"Remove Disk from Library": "從照片庫移除磁碟",
|
||||
"All the photos imported from this disk are included.": "包含從此磁碟匯入的所有照片。",
|
||||
"That includes the photos in the folders inside it.": "其中資料夾裡的照片也包含在內。"
|
||||
}
|
||||
@@ -258,6 +258,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.zoom" => {
|
||||
let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else {
|
||||
return err("ui.zoom: factor must be a finite positive number (1 = unchanged)");
|
||||
};
|
||||
app.synthetic.push(egui::Event::Zoom(factor));
|
||||
ctx.request_repaint();
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.set" => {
|
||||
let mut v = serde_json::to_value(&app.ui).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, p);
|
||||
@@ -352,7 +360,7 @@ pub fn default_export_dir() -> String {
|
||||
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{Destination, ExportOptions, export_batch};
|
||||
let p = &app.session.export_params(p)?;
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
||||
if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
|
||||
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
|
||||
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);
|
||||
|
||||
@@ -1358,6 +1358,122 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The import review offers what to do with files that are in Recently Deleted (issue #298):
|
||||
/// they are unchecked until Restore or Import as new is chosen; then confirming restores the
|
||||
/// photo (with its edits) or imports the file afresh.
|
||||
#[test]
|
||||
fn import_review_offers_recently_deleted_files() {
|
||||
for (choice, tag) in [("restore", "r"), ("fresh", "f")] {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..2u8 {
|
||||
let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data };
|
||||
let png =
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(dir.join(format!("img{i}.png")), png).unwrap();
|
||||
}
|
||||
let services = crate::Services { png: None, ..Default::default() };
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services);
|
||||
app.ui.view = crate::state::ViewMode::PhotoGrid;
|
||||
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
|
||||
let t = Duration::from_secs(10);
|
||||
let r =
|
||||
h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t);
|
||||
let id = r["result"]["imported"][0].as_u64().unwrap();
|
||||
h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t);
|
||||
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t);
|
||||
h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t);
|
||||
h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })));
|
||||
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") };
|
||||
assert_eq!(opts.selected_paths().len(), 1, "only the new file: the trashed one waits for a choice");
|
||||
opts.set_on_deleted(choice);
|
||||
assert_eq!(opts.selected_paths().len(), 2, "the trashed file is checked once a choice is made");
|
||||
// a per-cell choice survives switching between the two options, and "Leave them" unchecks
|
||||
let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).unwrap();
|
||||
opts.checked[trashed] = false;
|
||||
opts.set_on_deleted(if choice == "restore" { "fresh" } else { "restore" });
|
||||
assert_eq!(opts.selected_paths().len(), 1, "still unchecked");
|
||||
opts.set_on_deleted("");
|
||||
assert_eq!(opts.selected_paths().len(), 1);
|
||||
opts.set_on_deleted(choice);
|
||||
assert_eq!(opts.selected_paths().len(), 2);
|
||||
let r = h.request("ui.dialog.confirm", json!({}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
// frames until the import has finished, so its (timed) toast is read before it expires
|
||||
let t0 = std::time::Instant::now();
|
||||
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.import.is_none(), "finished");
|
||||
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
|
||||
let photos = &h.app.session.catalog;
|
||||
assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2, "{choice}");
|
||||
let old = photos.photo(lightcraft_catalog::PhotoId(id));
|
||||
if choice == "restore" {
|
||||
assert!(toast.contains("1 restored"), "{toast}");
|
||||
assert!(old.is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits");
|
||||
assert_eq!(photos.len(), 2);
|
||||
} else {
|
||||
assert!(old.is_none(), "the trashed record is gone");
|
||||
assert_eq!(photos.len(), 2);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its
|
||||
/// corner: the photo grid takes the new height, and the window then keeps its size (issue #337).
|
||||
#[test]
|
||||
fn import_review_dialog_resizes() {
|
||||
let mut h = demo([1400.0, 1000.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
let candidates = (0..60)
|
||||
.map(|i| lightcraft_engine::import::ImportCandidate {
|
||||
path: format!("/lc-test/img{i}.png"),
|
||||
name: format!("img{i}.png"),
|
||||
error: Some("not read in this test".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(candidates)) });
|
||||
let rect = |h: &mut Headless| {
|
||||
let r = h.request("ui.widgets", json!({}), t);
|
||||
let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "dialog:window")).expect("dialog on screen");
|
||||
[0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap())
|
||||
};
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
let r0 = rect(&mut h);
|
||||
assert!(r0[3] > 600.0, "{r0:?}");
|
||||
// the Copy options add rows below the grid: the grid makes room, the window doesn't grow
|
||||
for copy in [true, false] {
|
||||
if let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog {
|
||||
opts.copy = copy;
|
||||
}
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
let r = rect(&mut h);
|
||||
assert!((r[3] - r0[3]).abs() < 0.5, "copy {copy}: {r0:?} → {r:?}");
|
||||
}
|
||||
let (x, y) = (r0[0] + r0[2] - 3.0, r0[1] + r0[3] - 3.0);
|
||||
h.request("ui.drag", json!({"x": x, "y": y, "toX": x + 160.0, "toY": y + 120.0, "steps": 12}), t);
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
let r1 = rect(&mut h);
|
||||
assert!(r1[2] > r0[2] + 100.0 && r1[3] > r0[3] + 80.0, "dragging the corner resized it: {r0:?} → {r1:?}");
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
let r2 = rect(&mut h);
|
||||
assert!((r2[2] - r1[2]).abs() < 0.5 && (r2[3] - r1[3]).abs() < 0.5, "the dialog kept its size: {r1:?} → {r2:?}");
|
||||
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })), "still open");
|
||||
}
|
||||
|
||||
/// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo
|
||||
/// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once
|
||||
/// restored a re-add selects it in All Photos.
|
||||
@@ -1417,6 +1533,8 @@ mod tests {
|
||||
h.settle(SETTLE);
|
||||
assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu");
|
||||
|
||||
// Empty Recently Deleted is offered while the trash is the source
|
||||
assert!(photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()));
|
||||
let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored");
|
||||
@@ -1426,6 +1544,7 @@ mod tests {
|
||||
assert_eq!(h.app.session.selection.ids, vec![id]);
|
||||
let toast = h.app.ui.toast.clone().expect("toast").0;
|
||||
assert!(toast.contains("All Photos"), "{toast}");
|
||||
assert!(!photo_items(&h.app).contains(&"library.emptyRecentlyDeleted".to_string()), "Empty is for the trash view only");
|
||||
|
||||
// deleted permanently, the file imports afresh as a new photo
|
||||
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t);
|
||||
@@ -1782,7 +1901,7 @@ mod tests {
|
||||
assert_ne!(r["ok"], true, "{r}");
|
||||
let fit = h.app.image_rect.unwrap();
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0));
|
||||
assert!(h.app.ui.zoom_anim, "animation started");
|
||||
// every animation frame asks for the same (final-size) render
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
@@ -1801,12 +1920,185 @@ mod tests {
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
// Z zooms to the same ratio as a click
|
||||
h.request("ui.key", json!({"key": "z"}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300));
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(300.0));
|
||||
h.request("ui.key", json!({"key": "z"}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_navigation_keeps_the_image_cursor_between_events() {
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), t);
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
|
||||
h.settle(SETTLE);
|
||||
let anchor = h.app.canvas_rect.unwrap().center();
|
||||
h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t);
|
||||
for (tool, expected) in [
|
||||
("", egui::CursorIcon::Grab),
|
||||
("wbPicker", egui::CursorIcon::Crosshair),
|
||||
("colorRange", egui::CursorIcon::Crosshair),
|
||||
("pointColor", egui::CursorIcon::Crosshair),
|
||||
("tat:curve", egui::CursorIcon::ResizeVertical),
|
||||
] {
|
||||
h.app.ui.tool = tool.into();
|
||||
for event in [
|
||||
None,
|
||||
Some(egui::Event::Zoom(1.01)),
|
||||
None,
|
||||
Some(egui::Event::Zoom(1.01)),
|
||||
Some(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(10.0, -10.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}),
|
||||
None,
|
||||
] {
|
||||
let raw = HeadlessView::raw_input(h.size, h.pixels_per_point, h.time, event.into_iter().collect());
|
||||
let mut cursor = egui::CursorIcon::Default;
|
||||
h.view.run(raw, |ui| {
|
||||
h.app.logic(ui.ctx());
|
||||
h.app.ui(ui);
|
||||
cursor = ui.ctx().output(|output| output.cursor_icon);
|
||||
});
|
||||
h.time += FRAME_DT;
|
||||
assert_eq!(cursor, expected, "cursor changed between navigation events with tool {tool:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_pinch_anchors_fractional_zoom_and_scroll_pans() {
|
||||
use crate::state::Zoom;
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
h.pixels_per_point = 2.0;
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t);
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), t);
|
||||
h.settle(SETTLE);
|
||||
let before = h.app.image_rect.unwrap();
|
||||
let anchor = h.app.canvas_rect.unwrap().center() + egui::vec2(30.0, -20.0);
|
||||
h.request("ui.move", json!({"x": anchor.x, "y": anchor.y}), t);
|
||||
let point = (anchor - before.min) / before.size();
|
||||
h.request("ui.zoom", json!({"factor": 1.001}), t);
|
||||
let after = h.app.image_rect.unwrap();
|
||||
assert!((after.width() / before.width() - 1.001).abs() < 0.00001);
|
||||
assert!(((anchor - after.min) / after.size() - point).length() < 0.00001, "point under pointer moved");
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(p) if (p - 100.1).abs() < 0.001), "{:?}", h.app.ui.zoom);
|
||||
assert!(!h.app.ui.zoom_anim, "pinch follows the fingers immediately");
|
||||
|
||||
h.request("ui.scroll", json!({"dx": 40.0, "dy": -30.0}), t);
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
}
|
||||
let panned = h.app.image_rect.unwrap();
|
||||
assert!(panned.left() > after.left() && panned.top() < after.top(), "two-finger pan must move both axes");
|
||||
h.request("ui.scroll", json!({"dx": 100000.0, "dy": -100000.0}), t);
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
}
|
||||
let edge = h.app.image_rect.unwrap();
|
||||
let area = h.app.canvas_rect.unwrap().shrink(24.0);
|
||||
assert!((edge.left() - area.left()).abs() < 0.01 && (edge.bottom() - area.bottom()).abs() < 0.01, "{edge:?} vs {area:?}");
|
||||
|
||||
h.request("ui.zoom", json!({"factor": 1e20}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(800.0));
|
||||
h.request("ui.zoom", json!({"factor": 0.000001}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
assert_eq!(h.app.ui.pan, (0.5, 0.5));
|
||||
// Panel / toolbar gestures must not move the image.
|
||||
h.request("ui.move", json!({"x": 10, "y": 10}), t);
|
||||
h.request("ui.zoom", json!({"factor": 2}), t);
|
||||
h.request("ui.scroll", json!({"dx": 100, "dy": 100}), t);
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
assert_eq!(h.app.ui.pan, (0.5, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() {
|
||||
use crate::state::Zoom;
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
h.request("ui.set", json!({"view": "detail", "leftPanel": false, "filmstrip": false}), t);
|
||||
h.settle(SETTLE);
|
||||
let active = h.app.session.active().unwrap();
|
||||
let settings = h.app.session.catalog.photo(active).unwrap().develop.clone();
|
||||
for right in ["crop", "masking", "remove", "redEye", "none"] {
|
||||
h.request("ui.set", json!({"right": right, "zoom": "fit", "pan": [0.5, 0.5]}), t);
|
||||
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
|
||||
h.request("ui.zoom", json!({"factor": 2}), t);
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch in {right}");
|
||||
assert_eq!(h.app.session.catalog.photo(active).unwrap().develop, settings, "navigation must not edit the photo");
|
||||
}
|
||||
h.request("ui.set", json!({"fullscreen": true, "zoom": "fit"}), t);
|
||||
h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t);
|
||||
h.request("ui.zoom", json!({"factor": 2}), t);
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "full-screen pinch");
|
||||
|
||||
h.request("ui.set", json!({"fullscreen": false, "zoom": "fit", "beforeAfter": "sideBySide"}), t);
|
||||
let canvas = h.app.canvas_rect.unwrap();
|
||||
h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t);
|
||||
h.request("ui.zoom", json!({"factor": 2}), t);
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "pinch over the Before pane");
|
||||
|
||||
h.request("ui.set", json!({"beforeAfter": "off", "zoom": "fit"}), t);
|
||||
h.request("engine.execute", json!({"command": "view.compare"}), t);
|
||||
// The left Compare pane must navigate too; zoom/pan are shared with the right pane.
|
||||
let canvas = h.app.canvas_rect.unwrap();
|
||||
h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t);
|
||||
h.request("ui.zoom", json!({"factor": 2}), t);
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Compare pinch");
|
||||
let pan = h.app.ui.pan;
|
||||
h.request("ui.scroll", json!({"dx": -50, "dy": -50}), t);
|
||||
assert_ne!(h.app.ui.pan, pan, "Compare pan");
|
||||
|
||||
h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t);
|
||||
h.request("engine.execute", json!({"command": "view.reference"}), t);
|
||||
h.request("ui.move", json!({"x": canvas.left() + canvas.width() * 0.25, "y": canvas.center().y}), t);
|
||||
h.request("ui.zoom", json!({"factor": 2}), t);
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "Reference pinch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_rejects_invalid_input_and_preserves_old_zoom_state() {
|
||||
use crate::state::Zoom;
|
||||
let mut h = demo([1000.0, 700.0]);
|
||||
let t = Duration::from_secs(10);
|
||||
assert_eq!(serde_json::from_value::<Zoom>(json!({"percent": 100})).unwrap(), Zoom::Percent(100.0));
|
||||
for factor in [json!(0), json!(-1), json!(1e308), json!("big"), Value::Null] {
|
||||
let r = h.request("ui.zoom", json!({"factor": factor}), t);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
}
|
||||
for params in [
|
||||
json!({"zoom": {"percent": -1}}),
|
||||
json!({"zoom": {"percent": 1e308}}),
|
||||
json!({"zoom": "oops"}),
|
||||
json!({"zoom": {"percent": 123.4}, "pan": [-1, 0.5]}),
|
||||
json!({"pan": [0.5, 1e308]}),
|
||||
json!({"pan": [0.5]}),
|
||||
] {
|
||||
assert!(h.app.run("view.navigate", params).is_err());
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit, "failed request changed zoom");
|
||||
assert_eq!(h.app.ui.pan, (0.5, 0.5), "failed request changed pan");
|
||||
}
|
||||
h.app.run("view.navigate", json!({"zoom": {"percent": 123.4}, "pan": [0.4, 0.6]})).unwrap();
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(123.4));
|
||||
assert_eq!(h.app.ui.pan, (0.4, 0.6));
|
||||
h.app.run("view.zoomIn", json!({})).unwrap();
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(200.0));
|
||||
h.app.run("view.zoomOut", json!({})).unwrap();
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Percent(100.0));
|
||||
|
||||
let area = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(1000.0, 700.0));
|
||||
for pan in [(0.0, 0.0), (1.0, 1.0)] {
|
||||
let image = crate::panels::detail::fit_rect(area, 10.0, Zoom::Percent(100.0), [4000, 400], 1.0, pan);
|
||||
assert_eq!(image.center().y, area.center().y, "smaller axis must stay centred");
|
||||
assert!(image.left() <= area.left() && image.right() >= area.right(), "pan exposed space outside the photo");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Navigator appears when zoomed in; clicking it pans to that point.
|
||||
#[test]
|
||||
fn navigator_pans_the_zoomed_loupe() {
|
||||
@@ -1821,7 +2113,7 @@ mod tests {
|
||||
h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t);
|
||||
let (u, v) = h.app.ui.pan;
|
||||
assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan);
|
||||
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)");
|
||||
assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0), "the click didn't reach the loupe (which would zoom out)");
|
||||
h.settle(SETTLE);
|
||||
let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap();
|
||||
let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7);
|
||||
|
||||
@@ -191,6 +191,12 @@ pub fn builtin_label(source: &str, builtin: bool) -> &str {
|
||||
if builtin { tr(source) } else { source }
|
||||
}
|
||||
|
||||
/// A profile's name for display: a built-in profile's is translated, an imported LUT's is shown as
|
||||
/// its file named it (it may happen to match a message, like "Vivid").
|
||||
pub fn profile_label<'a>(id: &str, name: &'a str) -> &'a str {
|
||||
builtin_label(name, lightcraft_engine::presets::profile(id).is_some())
|
||||
}
|
||||
|
||||
/// An Activity (history) step for display. The step's stored label stays English; generated steps
|
||||
/// are translated around the names they carry, and a user preset's name is kept verbatim.
|
||||
pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
|
||||
@@ -216,6 +222,15 @@ pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcra
|
||||
if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
|
||||
}
|
||||
|
||||
/// What the grid is titled for the session's source: [`source_label`], or a library folder's last
|
||||
/// two names (`photos/travel`; the folder's name is the user's and stays verbatim).
|
||||
pub fn source_title(session: &lightcraft_engine::Session) -> String {
|
||||
match (session.source, session.library_folder.as_deref()) {
|
||||
(lightcraft_engine::LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path),
|
||||
(source, _) => source_label(source, &session.catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
|
||||
/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
|
||||
/// catalog's own wording; every other language formats it with its `*-formats.json` date
|
||||
|
||||
+220
-25
@@ -1,11 +1,11 @@
|
||||
//! The import review dialog (File → Import Photos… / Import from Folder… / Import from Device):
|
||||
//! the source it scanned (a scanned folder is *not* added to Local), the files found under it as
|
||||
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked), the destination (add in
|
||||
//! place / copy or move into the library's `Originals/` or a chosen folder, filed by day, by month,
|
||||
//! into one folder or by a custom folder template, optionally renamed), an album
|
||||
//! (existing or new), a preset and keywords to apply. Importing runs on a worker thread (files are
|
||||
//! probed, copied or moved there) and its batches join the catalog between frames, with a progress
|
||||
//! window and Cancel; the whole import is one undo step.
|
||||
//! a grid of thumbnails with checkboxes (duplicates marked and unchecked; Shift-click sets a
|
||||
//! range), the destination (add in place / copy or move into the library's `Originals/` or a
|
||||
//! chosen folder, filed by day, by month, into one folder or by a custom folder template,
|
||||
//! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing
|
||||
//! runs on a worker thread (files are probed, copied or moved there) and its batches join the
|
||||
//! catalog between frames, with a progress window and Cancel; the whole import is one undo step.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
|
||||
@@ -21,6 +21,31 @@ use crate::widgets::register;
|
||||
/// Files per batch (each batch joins the catalog as it is ready, so the progress window updates).
|
||||
pub(crate) const BATCH: usize = 8;
|
||||
|
||||
/// The review dialog's first size (points). It can be resized; the photo grid takes the height.
|
||||
pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0];
|
||||
/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last
|
||||
/// frame. Their difference is the room the grid leaves below it.
|
||||
const GRID_BOTTOM: &str = "import-grid-bottom";
|
||||
const CONTENT_BOTTOM: &str = "import-content-bottom";
|
||||
/// Room below the grid before it has been measured (more than the options and buttons take).
|
||||
const DEFAULT_BELOW_GRID: f32 = 360.0;
|
||||
|
||||
/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free,
|
||||
/// in whole points so the window never grows by a fraction each frame, and at least one `row`.
|
||||
pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 {
|
||||
let h = (available - below.max(0.0)).floor();
|
||||
if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) }
|
||||
}
|
||||
|
||||
/// Remember where the review dialog's content ends this frame (after its buttons), for
|
||||
/// [`grid_height`] on the next.
|
||||
pub(crate) fn note_dialog_bottom(ui: &egui::Ui) {
|
||||
if !ui.is_sizing_pass() {
|
||||
let y = ui.min_rect().bottom();
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(CONTENT_BOTTOM), y));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ImportDialog {
|
||||
@@ -53,6 +78,13 @@ pub struct ImportDialog {
|
||||
pub metadata_preset: String,
|
||||
/// Copy: raws are copied as DNG.
|
||||
pub dng: bool,
|
||||
/// Per candidate: the library already has it in Recently Deleted.
|
||||
pub trashed: Vec<bool>,
|
||||
/// What to do with those: "" (leave them), `restore` or `fresh` (the `onDeleted` param).
|
||||
pub on_deleted: String,
|
||||
/// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]).
|
||||
#[serde(skip)]
|
||||
pub last_clicked: Option<usize>,
|
||||
}
|
||||
|
||||
impl ImportDialog {
|
||||
@@ -61,14 +93,51 @@ impl ImportDialog {
|
||||
ImportDialog { candidates, checked, ..Default::default() }
|
||||
}
|
||||
pub fn importable(&self, i: usize) -> bool {
|
||||
self.candidates.get(i).is_some_and(|c| c.duplicate.is_none() && c.error.is_none())
|
||||
self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty()))
|
||||
}
|
||||
/// Candidate `i` is a file whose photo is in Recently Deleted.
|
||||
pub fn is_trashed(&self, i: usize) -> bool {
|
||||
self.trashed.get(i).copied().unwrap_or(false)
|
||||
}
|
||||
/// Choose what happens to the files in Recently Deleted (`""`, `restore` or `fresh`); they are
|
||||
/// checked once there is a choice and unchecked again without one.
|
||||
pub fn set_on_deleted(&mut self, choice: &str) {
|
||||
// switching between restore and fresh keeps the user's per-cell choices
|
||||
let changed = self.on_deleted.is_empty() != choice.is_empty();
|
||||
self.on_deleted = choice.to_string();
|
||||
for i in (0..self.candidates.len()).filter(|_| changed) {
|
||||
if self.is_trashed(i)
|
||||
&& let Some(c) = self.checked.get_mut(i)
|
||||
{
|
||||
*c = !choice.is_empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A click on candidate `i` toggles its checkbox. With `shift`, every importable candidate
|
||||
/// from the last clicked one to `i` takes `i`'s new state, as in a file manager: click the
|
||||
/// first, Shift-click the last, and the whole range is checked (or unchecked).
|
||||
pub fn click(&mut self, i: usize, shift: bool) {
|
||||
if !self.importable(i) {
|
||||
return;
|
||||
}
|
||||
let Some(on) = self.checked.get(i).map(|c| !c) else { return };
|
||||
let from = if shift { self.last_clicked.filter(|a| *a < self.checked.len()).unwrap_or(i) } else { i };
|
||||
for k in from.min(i)..=from.max(i) {
|
||||
if self.importable(k)
|
||||
&& let Some(c) = self.checked.get_mut(k)
|
||||
{
|
||||
*c = on;
|
||||
}
|
||||
}
|
||||
self.last_clicked = Some(i);
|
||||
}
|
||||
pub fn selected_paths(&self) -> Vec<String> {
|
||||
self.candidates
|
||||
.iter()
|
||||
.zip(&self.checked)
|
||||
.filter(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none())
|
||||
.map(|(c, _)| c.path.clone())
|
||||
.enumerate()
|
||||
.filter(|(i, (_, on))| **on && self.importable(*i))
|
||||
.map(|(_, (c, _))| c.path.clone())
|
||||
.collect()
|
||||
}
|
||||
/// The `organize` param of `library.import` (`None` = the default, by day); an unusable
|
||||
@@ -104,6 +173,8 @@ pub struct ImportTask {
|
||||
pub done: usize,
|
||||
params: Value,
|
||||
pub imported: usize,
|
||||
/// Brought back from Recently Deleted (`onDeleted: restore`).
|
||||
pub restored: usize,
|
||||
pub duplicates: usize,
|
||||
/// The library photos the skipped duplicates match (shown when nothing new was added).
|
||||
existing: Vec<u64>,
|
||||
@@ -400,6 +471,14 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
app.renderer.forget_imports();
|
||||
let mut d = ImportDialog::new(out.candidates);
|
||||
d.trashed = d
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
c.duplicate.is_some()
|
||||
&& c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted))
|
||||
})
|
||||
.collect();
|
||||
d.copy = task.copy;
|
||||
d.sources = task.sources;
|
||||
app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) });
|
||||
@@ -461,6 +540,9 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
|
||||
(true, true) => "move",
|
||||
};
|
||||
let mut params = json!({"mode": mode, "keywords": d.keywords()});
|
||||
if !d.on_deleted.is_empty() {
|
||||
params["onDeleted"] = json!(d.on_deleted);
|
||||
}
|
||||
if let Some(a) = album {
|
||||
params["album"] = json!(a);
|
||||
}
|
||||
@@ -564,6 +646,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
|
||||
}
|
||||
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
|
||||
task.imported += len("imported");
|
||||
task.restored += len("restored");
|
||||
task.duplicates += len("duplicates");
|
||||
task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));
|
||||
task.failed += len("failed");
|
||||
@@ -573,7 +656,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
|
||||
log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or(""));
|
||||
}
|
||||
if task.first.is_none() {
|
||||
task.first = v["imported"].as_array().and_then(|a| a.first()).and_then(Value::as_u64);
|
||||
task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -586,7 +669,14 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
|
||||
/// The import is done (or cancelled): one undo step, select the first photo, say what happened.
|
||||
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
let steps = app.session.undo.len().saturating_sub(task.undo0);
|
||||
let label = crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
let label = if task.imported == 0 && task.restored > 0 {
|
||||
crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored))
|
||||
} else if task.restored > 0 {
|
||||
crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" })
|
||||
};
|
||||
app.session.merge_undo(steps, &label);
|
||||
if task.auto {
|
||||
if task.imported > 0 {
|
||||
@@ -606,7 +696,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
// nothing new, only files the library already has: show where they are (Recently Deleted is
|
||||
// easy to miss, and the side panel that lists it starts collapsed)
|
||||
if task.imported == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) {
|
||||
if task.imported == 0 && task.restored == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) {
|
||||
return;
|
||||
}
|
||||
let mut msg = if task.cancelled {
|
||||
@@ -614,6 +704,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
||||
} else {
|
||||
crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported))
|
||||
};
|
||||
if task.restored > 0 {
|
||||
msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored));
|
||||
}
|
||||
if task.params["mode"] == "move" {
|
||||
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
|
||||
if task.kept > 0 {
|
||||
@@ -731,18 +824,29 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
let avail = ui.available_width();
|
||||
let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize;
|
||||
let rows = n.div_ceil(cols);
|
||||
egui::ScrollArea::vertical().id_salt("import-grid").max_height(330.0).auto_shrink([false, true]).show_viewport(ui, |ui, viewport| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover());
|
||||
for i in 0..n {
|
||||
let (c, r) = (i % cols, i / cols);
|
||||
let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0));
|
||||
if !local.intersects(viewport.expand(cell)) {
|
||||
continue;
|
||||
}
|
||||
let rect = local.translate(area.min.to_vec2());
|
||||
candidate_cell(app, ui, d, i, rect);
|
||||
}
|
||||
// as tall as the window leaves after the options and buttons below it (measured last frame),
|
||||
// so resizing the dialog resizes the grid
|
||||
let below = ui.ctx().data(|m| {
|
||||
let y = |key: &str| m.get_temp::<f32>(egui::Id::new(key));
|
||||
y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid)
|
||||
});
|
||||
let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0);
|
||||
let grid =
|
||||
egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover());
|
||||
for i in 0..n {
|
||||
let (c, r) = (i % cols, i / cols);
|
||||
let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0));
|
||||
if !local.intersects(viewport.expand(cell)) {
|
||||
continue;
|
||||
}
|
||||
let rect = local.translate(area.min.to_vec2());
|
||||
candidate_cell(app, ui, d, i, rect);
|
||||
}
|
||||
});
|
||||
if !ui.is_sizing_pass() {
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(GRID_BOTTOM), grid.inner_rect.bottom()));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// options
|
||||
if !d.sources.is_empty() {
|
||||
@@ -895,6 +999,32 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.trashed.iter().any(|t| *t) {
|
||||
field(ui, "In Recently Deleted", |ui| {
|
||||
let cur = match d.on_deleted.as_str() {
|
||||
"restore" => "Restore them",
|
||||
"fresh" => "Import as new",
|
||||
_ => "Leave them",
|
||||
};
|
||||
let mut pick = None;
|
||||
egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
for (v, label) in [("", "Leave them"), ("restore", "Restore them"), ("fresh", "Import as new")] {
|
||||
if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() {
|
||||
pick = Some(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(v) = pick {
|
||||
d.set_on_deleted(v);
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
}
|
||||
let albums: Vec<(u64, String)> = {
|
||||
let mut v: Vec<(u64, String)> =
|
||||
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
|
||||
@@ -1000,6 +1130,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
|
||||
}
|
||||
let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted));
|
||||
let badge = match (&c.duplicate, &c.error) {
|
||||
(Some(_), _) if in_trash && ok => Some(if d.on_deleted == "restore" { "Will be restored" } else { "Will be replaced" }),
|
||||
(Some(_), _) if in_trash => Some("In Recently Deleted"),
|
||||
(Some(r), _) if r == "path" => Some("In library"),
|
||||
(Some(_), _) => Some("Duplicate"),
|
||||
@@ -1025,7 +1156,8 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
|
||||
);
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() && ok {
|
||||
d.checked[i] = !d.checked[i];
|
||||
let shift = ui.input(|input| input.modifiers.shift);
|
||||
d.click(i, shift);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1117,7 +1249,7 @@ pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}";
|
||||
/// Where the first selected photo would be copied to (destination, folders, name), for the
|
||||
/// dialog's example line; `None` without a photo or with an unusable folder template.
|
||||
pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<String> {
|
||||
let c = d.candidates.iter().zip(&d.checked).find(|(c, on)| **on && c.duplicate.is_none() && c.error.is_none()).map(|(c, _)| c)?;
|
||||
let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?;
|
||||
let organize = match d.organize_param().ok()? {
|
||||
Some(o) => lightcraft_engine::import::Organize::parse(&o)?,
|
||||
None => Default::default(),
|
||||
@@ -1179,3 +1311,66 @@ fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid_height_follows_the_window() {
|
||||
// the grid takes what the options and buttons leave, in whole points
|
||||
assert_eq!(grid_height(700.0, 300.0, 142.0), 400.0);
|
||||
assert_eq!(grid_height(1000.5, 300.0, 142.0), 700.0);
|
||||
// never less than one row of thumbnails
|
||||
assert_eq!(grid_height(200.0, 300.0, 142.0), 142.0);
|
||||
// a bad measurement doesn't take the grid away or make it endless
|
||||
assert_eq!(grid_height(700.0, -50.0, 142.0), 700.0);
|
||||
assert_eq!(grid_height(700.0, f32::NAN, 142.0), 700.0);
|
||||
assert_eq!(grid_height(f32::INFINITY, 300.0, 142.0), DIALOG_SIZE[1] - DEFAULT_BELOW_GRID);
|
||||
}
|
||||
|
||||
/// Six candidates, all checked; candidate 3 is a duplicate (unchecked, not importable).
|
||||
fn dialog() -> ImportDialog {
|
||||
let mut candidates: Vec<ImportCandidate> = (0..6).map(|i| ImportCandidate { path: format!("img{i}.jpg"), ..Default::default() }).collect();
|
||||
candidates[3].duplicate = Some("content".into());
|
||||
ImportDialog::new(candidates)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_toggles_one_candidate() {
|
||||
let mut d = dialog();
|
||||
d.click(1, false);
|
||||
assert_eq!(d.checked, [true, false, true, false, true, true]);
|
||||
d.click(1, false);
|
||||
assert_eq!(d.checked, [true, true, true, false, true, true]);
|
||||
d.click(3, false);
|
||||
assert_eq!(d.checked, [true, true, true, false, true, true], "a duplicate can't be checked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_sets_the_range_to_the_clicked_state() {
|
||||
let mut d = dialog();
|
||||
d.click(1, false);
|
||||
d.click(4, true);
|
||||
assert_eq!(d.checked, [true, false, false, false, false, true], "1..=4 unchecked, the duplicate left alone");
|
||||
assert_eq!(d.selected_paths(), ["img0.jpg", "img5.jpg"]);
|
||||
// backwards from the new anchor (4) re-checks 2..=4
|
||||
d.click(2, true);
|
||||
assert_eq!(d.checked, [true, false, true, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_without_an_earlier_click_toggles_one() {
|
||||
let mut d = dialog();
|
||||
d.click(2, true);
|
||||
assert_eq!(d.checked, [true, true, false, false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_with_a_stale_anchor_toggles_one() {
|
||||
let mut d = dialog();
|
||||
d.last_clicked = Some(99);
|
||||
d.click(2, true);
|
||||
assert_eq!(d.checked, [true, true, false, false, true, true]);
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,12 @@ pub mod widgets;
|
||||
#[cfg(test)]
|
||||
mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_filmstrip;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
@@ -55,6 +59,8 @@ use serde_json::Value;
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
pub use state::UiState;
|
||||
|
||||
const TOAST_SECONDS: f64 = 1.4;
|
||||
|
||||
pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
|
||||
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
|
||||
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
@@ -268,6 +274,20 @@ impl LightcraftApp {
|
||||
return r;
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
if r.is_ok() && id == "photo.label" {
|
||||
let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse);
|
||||
let text = match label {
|
||||
Some(label) => {
|
||||
let name =
|
||||
self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into());
|
||||
i18n::tr_format!("{name} Label", name = name)
|
||||
}
|
||||
None => i18n::tr("Color label cleared").into(),
|
||||
};
|
||||
// Native menu clicks can arrive before logic() updates last_time after an idle gap.
|
||||
let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time);
|
||||
self.ui.toast = Some((text, now + TOAST_SECONDS, label));
|
||||
}
|
||||
if let Err(e) = &r {
|
||||
log::warn!("{id}: {e}");
|
||||
self.ui.status = e.clone();
|
||||
@@ -342,7 +362,7 @@ impl LightcraftApp {
|
||||
self.ui.unsaved_seen = true;
|
||||
let t = ctx.input(|i| i.time);
|
||||
let what = if n == 1 { "1 change".to_string() } else { format!("{n} changes") };
|
||||
self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0));
|
||||
self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0, None));
|
||||
}
|
||||
(None, true) => {
|
||||
self.ui.unsaved_seen = false;
|
||||
@@ -354,13 +374,13 @@ impl LightcraftApp {
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
|
||||
self.toast_for(ctx, text, 1.4);
|
||||
self.toast_for(ctx, text, TOAST_SECONDS);
|
||||
}
|
||||
|
||||
/// A toast that stays `secs` seconds (messages that say where to look or what to do next).
|
||||
pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into<String>, secs: f64) {
|
||||
let t = ctx.input(|i| i.time);
|
||||
self.ui.toast = Some((text.into(), t + secs));
|
||||
self.ui.toast = Some((text.into(), t + secs, None));
|
||||
}
|
||||
|
||||
/// AI masks: apply finished background requests (clicks, descriptions, detail passes) and
|
||||
@@ -438,7 +458,7 @@ impl LightcraftApp {
|
||||
/// A toast for an error the user has to read and act on (stays 6 s).
|
||||
pub fn toast_error(&mut self, ctx: &egui::Context, text: impl Into<String>) {
|
||||
let t = ctx.input(|i| i.time);
|
||||
self.ui.toast = Some((text.into(), t + 6.0));
|
||||
self.ui.toast = Some((text.into(), t + 6.0, None));
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
@@ -890,6 +910,7 @@ mod drop_tests {
|
||||
#[derive(Default)]
|
||||
pub struct Caches {
|
||||
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
|
||||
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
|
||||
people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
|
||||
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
|
||||
counts: Option<(u64, LibraryCounts)>,
|
||||
@@ -926,6 +947,17 @@ pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
|
||||
}
|
||||
|
||||
impl Caches {
|
||||
/// The folders the library's photos were imported from.
|
||||
pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> {
|
||||
match &self.folder_tree {
|
||||
Some((r, t)) if *r == cat.revision => t.clone(),
|
||||
_ => {
|
||||
let t = std::sync::Arc::new(cat.folder_tree());
|
||||
self.folder_tree = Some((cat.revision, t.clone()));
|
||||
t
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The library's keyword tree.
|
||||
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
|
||||
match &self.keyword_tree {
|
||||
|
||||
@@ -272,6 +272,8 @@ fn host_supports(app: &LightcraftApp, id: &str) -> bool {
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
"photo.restore" | "photo.deletePermanently" => selection_deleted(app),
|
||||
"photo.delete" => !selection_deleted(app),
|
||||
// only where the trash is on screen
|
||||
"library.emptyRecentlyDeleted" => app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -342,6 +344,7 @@ fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
|
||||
"photo.delete" if n > 1 => crate::i18n::tr_format!("Delete {n} Photos", n = n),
|
||||
"photo.virtualCopy" if n > 1 => crate::i18n::tr_format!("Create {n} Virtual Copies", n = n),
|
||||
"dialog.rename" if n > 1 => crate::i18n::tr_format!("Rename {n} Photos…", n = n),
|
||||
"app.showInFinder" => crate::i18n::tr(crate::menus::reveal_label()).to_string(),
|
||||
_ => label.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -573,7 +576,9 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
}
|
||||
|
||||
fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {
|
||||
item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id))
|
||||
// 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() };
|
||||
item(&e.id, Value::Null, live_label(app, &e.id, &e.label), shortcut, e.enabled, checked(app, &e.id))
|
||||
}
|
||||
|
||||
/// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus.
|
||||
|
||||
@@ -67,6 +67,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"),
|
||||
// the ratio a click (and Z / Space) zooms to
|
||||
("view.clickZoom", "Click Zoom Ratio", None, ""),
|
||||
("view.navigate", "Set Image Zoom and Pan", None, ""),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
|
||||
("view.clipping", "Show Clipping", Some("J"), "View"),
|
||||
@@ -463,12 +464,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.zoom100" => {
|
||||
app.ui.zoom = Zoom::Percent(100);
|
||||
app.ui.zoom = Zoom::Percent(100.0);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.zoomToggle" => {
|
||||
// the same ratio a click on the photo zooms to
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit };
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit };
|
||||
app.ui.zoom_anim = true;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -484,19 +485,44 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(json!({"ratio": app.ui.click_zoom / 100}))
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let steps = [25u32, 50, 100, 200, 400, 800];
|
||||
let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0];
|
||||
let cur = match app.ui.zoom {
|
||||
Zoom::Percent(p) => p,
|
||||
_ => 25,
|
||||
_ => 25.0,
|
||||
};
|
||||
let next = if id == "view.zoomIn" {
|
||||
steps.iter().find(|s| **s > cur).copied().unwrap_or(800)
|
||||
steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0)
|
||||
} else {
|
||||
steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0)
|
||||
steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0)
|
||||
};
|
||||
app.ui.zoom = if next == 0 { Zoom::Fit } else { Zoom::Percent(next) };
|
||||
app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) };
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.navigate" => {
|
||||
// {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre).
|
||||
// Validate the complete request before changing either part of the viewport.
|
||||
let zoom = match p.get("zoom") {
|
||||
Some(v) => match serde_json::from_value::<Zoom>(v.clone()) {
|
||||
Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => {
|
||||
return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into()));
|
||||
}
|
||||
Ok(z) => z,
|
||||
Err(e) => return Some(Err(format!("view.navigate: {e}"))),
|
||||
},
|
||||
None => app.ui.zoom,
|
||||
};
|
||||
let pan = match p.get("pan") {
|
||||
Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) {
|
||||
Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y),
|
||||
_ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())),
|
||||
},
|
||||
None => app.ui.pan,
|
||||
};
|
||||
app.ui.zoom = zoom;
|
||||
app.ui.pan = pan;
|
||||
app.ui.zoom_anim = false;
|
||||
Ok(json!({"zoom": zoom, "pan": pan}))
|
||||
}
|
||||
"view.clipping" => {
|
||||
app.ui.show_clipping = !app.ui.show_clipping;
|
||||
Ok(Value::Null)
|
||||
@@ -1240,7 +1266,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
crate::links::open(app, url)
|
||||
}
|
||||
"app.exportPrevious" => match app.session.last_export.clone() {
|
||||
Some(prev) => crate::control::export_active(app, &prev),
|
||||
Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)),
|
||||
None => Err("nothing exported yet — use Export…".into()),
|
||||
},
|
||||
_ => return None,
|
||||
@@ -1329,6 +1355,17 @@ pub fn confirm_delete(app: &mut LightcraftApp) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Platform-appropriate label for revealing a file in the system file manager.
|
||||
pub fn reveal_label() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"Show in Finder"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Show in Explorer"
|
||||
} else {
|
||||
"Show in File Manager"
|
||||
}
|
||||
}
|
||||
|
||||
/// Reveal the active photo's original in the system file manager.
|
||||
fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
|
||||
let id = app.session.active().ok_or("no photo selected")?;
|
||||
|
||||
@@ -268,7 +268,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Err(_) => {
|
||||
let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default();
|
||||
let now = ctx.input(|i| i.time);
|
||||
app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5));
|
||||
app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None));
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,17 +178,17 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
|
||||
let zoom_label = match app.ui.zoom {
|
||||
Zoom::Fit => "Fit".to_string(),
|
||||
Zoom::Fill => "Fill".to_string(),
|
||||
Zoom::Percent(p) => format!("{p}%"),
|
||||
Zoom::Percent(p) => format!("{p:.0}%"),
|
||||
};
|
||||
let zr = crate::widgets::dropdown(&mut child, "zoom", crate::i18n::tr(&zoom_label), t.font(13.0), t.text_label);
|
||||
egui::Popup::menu(&zr).show(|ui| {
|
||||
for (label, z) in [
|
||||
("Fit", Zoom::Fit),
|
||||
("Fill", Zoom::Fill),
|
||||
("50%", Zoom::Percent(50)),
|
||||
("100%", Zoom::Percent(100)),
|
||||
("200%", Zoom::Percent(200)),
|
||||
("400%", Zoom::Percent(400)),
|
||||
("50%", Zoom::Percent(50.0)),
|
||||
("100%", Zoom::Percent(100.0)),
|
||||
("200%", Zoom::Percent(200.0)),
|
||||
("400%", Zoom::Percent(400.0)),
|
||||
] {
|
||||
if ui.selectable_label(app.ui.zoom == z, label).clicked() {
|
||||
app.ui.zoom = z;
|
||||
|
||||
@@ -160,7 +160,14 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
);
|
||||
let aspect = frame.aspect() as f32;
|
||||
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
|
||||
let img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan);
|
||||
let mut img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan);
|
||||
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
|
||||
if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) {
|
||||
img = super::detail::fit_rect(img_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
} else if resp.dragged() {
|
||||
super::detail::pan_image(app, img_area, img, resp.drag_delta());
|
||||
}
|
||||
}
|
||||
let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize;
|
||||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||||
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) {
|
||||
@@ -171,23 +178,28 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
}
|
||||
let p = ui.painter_at(img_area);
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
let mut display = img;
|
||||
if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) {
|
||||
p.image(tex.tex.id(), img, uv, Color32::WHITE);
|
||||
let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32;
|
||||
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 if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
|
||||
p.image(tex.tex.id(), img, uv, Color32::WHITE);
|
||||
let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32;
|
||||
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));
|
||||
}
|
||||
if photo.flag == Flag::Reject {
|
||||
p.rect_filled(img, 0.0, Color32::from_black_alpha(110));
|
||||
p.rect_filled(display, 0.0, Color32::from_black_alpha(110));
|
||||
}
|
||||
let active = app.session.selection.active == Some(id);
|
||||
if active {
|
||||
p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
}
|
||||
// caption: label · file name · stars · flag
|
||||
// caption right under the image (at the bottom when zoomed in)
|
||||
let cap_top = (img.bottom() + 2.0).min(img_area.bottom());
|
||||
let cap_top = (display.bottom() + 2.0).min(img_area.bottom());
|
||||
let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h));
|
||||
let pt = ui.painter();
|
||||
let mut x = cap.left() + 4.0;
|
||||
@@ -217,8 +229,8 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject),
|
||||
_ => {}
|
||||
}
|
||||
register(ui.ctx(), format!("cull:{}", id.0), img);
|
||||
(img, resp)
|
||||
register(ui.ctx(), format!("cull:{}", id.0), display);
|
||||
(display, resp)
|
||||
}
|
||||
|
||||
fn slot_index(s: Slot) -> u8 {
|
||||
@@ -228,15 +240,6 @@ fn slot_index(s: Slot) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag-to-pan when zoomed in (shared by both compare panes: zoom and pan are synced).
|
||||
fn pan(app: &mut LightcraftApp, resp: &egui::Response, img: Rect) {
|
||||
if app.ui.zoom != Zoom::Fit && resp.dragged() {
|
||||
let d = resp.drag_delta();
|
||||
let (px, py) = app.ui.pan;
|
||||
app.ui.pan = ((px - d.x / img.width().max(1.0)).clamp(0.0, 1.0), (py - d.y / img.height().max(1.0)).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let canvas = area_and_filmstrip(app, ui);
|
||||
let Some((sel, cand)) = compare_pair(app) else {
|
||||
@@ -255,12 +258,11 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if i == 1 {
|
||||
app.image_rect = Some(img);
|
||||
}
|
||||
pan(app, &resp, img);
|
||||
if resp.clicked() {
|
||||
select_pair(app, sel, cand, id);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100.0) } else { Zoom::Fit };
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(crate::i18n::tr("Swap")).clicked() {
|
||||
@@ -356,9 +358,8 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let left = Rect::from_min_size(area.min, vec2(half, area.height()));
|
||||
let right = Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height()));
|
||||
let (_, lresp) = photo_tile(app, ui, r, Slot::Compare(0), left, "Reference", zoom);
|
||||
let (img, resp) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom);
|
||||
let (img, _) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom);
|
||||
app.image_rect = Some(img);
|
||||
pan(app, &resp, img);
|
||||
lresp.context_menu(|ui| {
|
||||
if ui.button(crate::i18n::tr("Clear Reference")).clicked() {
|
||||
app.ui.reference = None;
|
||||
|
||||
@@ -94,6 +94,10 @@ impl CanvasMap {
|
||||
}
|
||||
|
||||
pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
|
||||
let zoom = match zoom {
|
||||
Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit,
|
||||
_ => zoom,
|
||||
};
|
||||
let (aw, ah) = (area.width(), area.height());
|
||||
let (w, h) = match zoom {
|
||||
Zoom::Fit => {
|
||||
@@ -112,19 +116,77 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2],
|
||||
}
|
||||
Zoom::Percent(p) => {
|
||||
// full-resolution pixels at p% (the photo's native width)
|
||||
let w = img_px[0] as f32 * p as f32 / 100.0 / ppp;
|
||||
let w = img_px[0] as f32 * p / 100.0 / ppp;
|
||||
(w, w / aspect)
|
||||
}
|
||||
};
|
||||
let c = if w <= aw && h <= ah {
|
||||
area.center()
|
||||
} else {
|
||||
// pan: which normalized point of the image sits at the area centre
|
||||
pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h)
|
||||
};
|
||||
let pan = bounded_pan(area, vec2(w, h), pan);
|
||||
let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h);
|
||||
Rect::from_center_size(c, vec2(w, h))
|
||||
}
|
||||
|
||||
/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can
|
||||
/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata.
|
||||
pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect {
|
||||
let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32);
|
||||
let scale = (area.width() / w).min(area.height() / h);
|
||||
Rect::from_center_size(area.center(), vec2(w * scale, h * scale))
|
||||
}
|
||||
|
||||
/// Keep the viewport inside the photo; axes smaller than the viewport stay centred.
|
||||
fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) {
|
||||
let bound = |visible: f32, image: f32, centre: f32| {
|
||||
let centre = if centre.is_finite() { centre } else { 0.5 };
|
||||
if image > visible.max(0.0) {
|
||||
let half = visible.max(0.0) / image / 2.0;
|
||||
centre.clamp(half, 1.0 - half)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
};
|
||||
(bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1))
|
||||
}
|
||||
|
||||
pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) {
|
||||
let centre = area.center() - img.min - delta;
|
||||
let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0)));
|
||||
let _ = app.run("view.navigate", json!({"pan": pan}));
|
||||
}
|
||||
|
||||
/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view.
|
||||
pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool {
|
||||
if !resp.hovered() {
|
||||
return false;
|
||||
}
|
||||
let (factor, delta, anchor) =
|
||||
ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos())));
|
||||
if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) {
|
||||
return false;
|
||||
}
|
||||
let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false };
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let aspect = img.width() / img.height().max(1.0);
|
||||
let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5));
|
||||
let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32;
|
||||
let mut zoom = app.ui.zoom;
|
||||
let size = if factor != 1.0 {
|
||||
// Start at the displayed scale (including Fit/Fill and an interrupted click animation).
|
||||
let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0);
|
||||
zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) };
|
||||
fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size()
|
||||
} else {
|
||||
img.size()
|
||||
};
|
||||
// The image point under the pointer remains there as its size changes, then follows the pan.
|
||||
let point = (anchor - img.min) / img.size();
|
||||
let offset = area.center() - anchor - delta;
|
||||
let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0)));
|
||||
let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan}));
|
||||
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||||
ui.ctx().request_repaint();
|
||||
true
|
||||
}
|
||||
|
||||
/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly.
|
||||
fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
let t = if *anim { 0.22 } else { 0.0 };
|
||||
@@ -191,8 +253,31 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
_ => vec![area],
|
||||
};
|
||||
let main_area = *areas.last().unwrap_or(&area);
|
||||
let target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
let img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect);
|
||||
let visible_texture_aspect = app
|
||||
.renderer
|
||||
.textures
|
||||
.get(&Slot::Main)
|
||||
.filter(|t| t.photo == id)
|
||||
.or_else(|| app.renderer.textures.get(&Slot::Preview).filter(|t| t.photo == id))
|
||||
.or_else(|| app.renderer.thumb(id))
|
||||
.map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32);
|
||||
// Follow the texture only where it really differs (an unsupported raw's embedded JPEG with
|
||||
// another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop
|
||||
// tool's overlays need the frame's own geometry.
|
||||
let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect);
|
||||
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
|
||||
let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect);
|
||||
let mut navigating = false;
|
||||
for &view_area in &areas {
|
||||
let view_img = if view_area == main_area { img_rect } else { fit_rect(view_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan) };
|
||||
if navigate_gesture(app, ui, &resp, view_area, view_img, native) {
|
||||
navigating = true;
|
||||
target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
img_rect = target_rect;
|
||||
break;
|
||||
}
|
||||
}
|
||||
app.image_rect = Some(img_rect);
|
||||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
let interacting = app.session.interaction.is_some();
|
||||
@@ -262,7 +347,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
return "none";
|
||||
};
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let image_rect = fit_texture_rect(r, tex.size);
|
||||
p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
what
|
||||
};
|
||||
// soft proofing: a paper-white surround and the proof's name, as Lightroom shows it
|
||||
@@ -284,9 +370,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
} else if let Some((_key, label)) = &hover_key
|
||||
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
|
||||
{
|
||||
p.image(tex.tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let hover_rect = fit_texture_rect(img_rect, tex.size);
|
||||
p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE);
|
||||
let bg = Rect::from_min_size(img_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
|
||||
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
|
||||
shown = "hover";
|
||||
@@ -314,7 +401,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if app.ui.before_after == BeforeAfter::Split {
|
||||
let mid = img_rect.center().x;
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
||||
let left = Rect::from_min_max(img_rect.min, pos2(mid, img_rect.bottom()));
|
||||
let before_rect = fit_texture_rect(img_rect, tex.size);
|
||||
let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom()));
|
||||
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
|
||||
}
|
||||
p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
|
||||
@@ -322,7 +410,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if app.ui.before_after == BeforeAfter::SplitTopBottom {
|
||||
let mid = img_rect.center().y;
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
||||
let top = Rect::from_min_max(img_rect.min, pos2(img_rect.right(), mid));
|
||||
let before_rect = fit_texture_rect(img_rect, tex.size);
|
||||
let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y));
|
||||
p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE);
|
||||
}
|
||||
p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE));
|
||||
@@ -332,7 +421,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
register(ui.ctx(), "canvas:image", img_rect);
|
||||
let map = CanvasMap::new(&frame, img_rect);
|
||||
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
|
||||
info_overlay(app, &p, canvas, &photo);
|
||||
if !fullscreen {
|
||||
filter_pill(app, ui, canvas);
|
||||
@@ -362,12 +450,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke);
|
||||
}
|
||||
}
|
||||
match right {
|
||||
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
|
||||
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d),
|
||||
_ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect),
|
||||
// Cursor selection must also run on gesture frames, when tool input is suppressed.
|
||||
if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) {
|
||||
general_cursor(app, ui, &resp, img_rect, main_area);
|
||||
}
|
||||
if !navigating {
|
||||
match right {
|
||||
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
|
||||
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d),
|
||||
_ => general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect),
|
||||
}
|
||||
}
|
||||
// drawn and hit-tested above the loupe and its tools: clicks on it pan
|
||||
navigator(app, ui, canvas, img_rect, id);
|
||||
@@ -669,8 +763,7 @@ fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
|
||||
/// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or
|
||||
/// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all
|
||||
/// in one interaction = one undo step).
|
||||
fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, target: &str) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap, target: &str) {
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
@@ -740,6 +833,26 @@ fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img: Rect, canvas: Rect) {
|
||||
// A panel or overlay under the pointer keeps its own cursor.
|
||||
if !resp.dragged() && !resp.hovered() {
|
||||
return;
|
||||
}
|
||||
let cursor = match app.ui.tool.as_str() {
|
||||
"wbPicker" | "colorRange" | "pointColor" => egui::CursorIcon::Crosshair,
|
||||
tool if tool.starts_with("tat:") => egui::CursorIcon::ResizeVertical,
|
||||
_ if img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0 => {
|
||||
if resp.dragged() {
|
||||
egui::CursorIcon::Grabbing
|
||||
} else {
|
||||
egui::CursorIcon::Grab
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
ui.ctx().set_cursor_icon(cursor);
|
||||
}
|
||||
|
||||
fn general_interaction(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
@@ -751,7 +864,6 @@ fn general_interaction(
|
||||
aspect: f32,
|
||||
) {
|
||||
if app.ui.tool == "wbPicker" {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
@@ -762,12 +874,11 @@ fn general_interaction(
|
||||
return;
|
||||
}
|
||||
if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) {
|
||||
targeted_drag(app, ui, resp, map, &target);
|
||||
targeted_drag(app, resp, map, &target);
|
||||
return;
|
||||
}
|
||||
if app.ui.tool == "colorRange" {
|
||||
// click: sample the colour for the selected mask's colour range; ⇧-click adds (up to 5)
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
@@ -780,7 +891,6 @@ fn general_interaction(
|
||||
return;
|
||||
}
|
||||
if app.ui.tool == "pointColor" {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
@@ -792,7 +902,7 @@ fn general_interaction(
|
||||
}
|
||||
return;
|
||||
}
|
||||
// click toggles Fit ↔ the click-zoom ratio (2:1/3:1/5:1) at the clicked point; drag pans when zoomed
|
||||
// click toggles Fit ↔ the chosen click-zoom ratio at the clicked point; drag pans when zoomed
|
||||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||||
if resp.double_clicked() || (resp.clicked() && !zoomed) {
|
||||
if let Some(q) = resp.interact_pointer_pos() {
|
||||
@@ -800,22 +910,14 @@ fn general_interaction(
|
||||
let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0);
|
||||
app.ui.pan = (u, v);
|
||||
}
|
||||
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(app.ui.click_zoom) } else { Zoom::Fit };
|
||||
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit };
|
||||
app.ui.zoom_anim = true;
|
||||
} else if resp.clicked() && zoomed {
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
app.ui.zoom_anim = true;
|
||||
}
|
||||
if zoomed {
|
||||
// only over the photo: a panel drawn earlier (sliders) must keep its own cursor
|
||||
if resp.dragged() || resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
if resp.dragged() {
|
||||
let dlt = resp.drag_delta();
|
||||
app.ui.pan.0 = (app.ui.pan.0 - dlt.x / img.width()).clamp(0.0, 1.0);
|
||||
app.ui.pan.1 = (app.ui.pan.1 - dlt.y / img.height()).clamp(0.0, 1.0);
|
||||
}
|
||||
if zoomed && resp.dragged() {
|
||||
pan_image(app, canvas, img, resp.drag_delta());
|
||||
}
|
||||
let _ = (native, aspect);
|
||||
}
|
||||
@@ -921,42 +1023,14 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
||||
{
|
||||
match app.gesture.clone() {
|
||||
Some(Gesture::CropHandle { handle, start, angle }) => {
|
||||
// The crop model decides what the drag does (anchor, aspect lock, image bounds): see
|
||||
// `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is.
|
||||
let n = to_straight(map.norm(q), angle, frame);
|
||||
let o = resp.interact_pointer_pos().map(|q0| q0 - resp.drag_delta()).unwrap_or(q);
|
||||
let _ = o;
|
||||
let mut r = start;
|
||||
let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n);
|
||||
let (dx, dy) = (n.x - orig.x, n.y - orig.y);
|
||||
match handle {
|
||||
0 => (r.x0, r.y0) = (start.x0 + dx, start.y0 + dy),
|
||||
1 => (r.x1, r.y0) = (start.x1 + dx, start.y0 + dy),
|
||||
2 => (r.x1, r.y1) = (start.x1 + dx, start.y1 + dy),
|
||||
3 => (r.x0, r.y1) = (start.x0 + dx, start.y1 + dy),
|
||||
4 => r.y0 = start.y0 + dy,
|
||||
5 => r.x1 = start.x1 + dx,
|
||||
6 => r.y1 = start.y1 + dy,
|
||||
7 => r.x0 = start.x0 + dx,
|
||||
_ => r = start.translate(lightcraft_geom::Vec2::new(dx, dy)),
|
||||
}
|
||||
// aspect lock
|
||||
if let Some((aw, ah)) = d.crop.aspect
|
||||
&& handle < 4
|
||||
{
|
||||
let (iw, ih) = (frame.ow, frame.oh);
|
||||
let a = if (iw >= ih) == (aw >= ah) { aw as f64 / ah as f64 } else { ah as f64 / aw as f64 };
|
||||
let w_px = (r.x1 - r.x0).abs() * iw;
|
||||
let h_px = w_px / a;
|
||||
let hn = h_px / ih;
|
||||
if handle == 0 || handle == 1 {
|
||||
r.y0 = r.y1 - hn;
|
||||
} else {
|
||||
r.y1 = r.y0 + hn;
|
||||
}
|
||||
}
|
||||
let rr = lightcraft_geom::Rect::new(r.x0.min(r.x1), r.y0.min(r.y1), r.x0.max(r.x1), r.y0.max(r.y1));
|
||||
if rr.width() > 0.02 && rr.height() > 0.02 {
|
||||
let _ = app.run("crop.set", json!({"rect": [rr.x0, rr.y0, rr.x1, rr.y1]}));
|
||||
}
|
||||
let _ = app.run(
|
||||
"crop.drag",
|
||||
json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1]}),
|
||||
);
|
||||
}
|
||||
Some(Gesture::CropRotate { start_angle, a0 }) => {
|
||||
let c = map.screen(Point::new(0.5, 0.5));
|
||||
@@ -1493,6 +1567,12 @@ fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
|
||||
|
||||
// ------------------------------------------------------------------------ filmstrip
|
||||
|
||||
/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an
|
||||
/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266).
|
||||
fn film_label(name: &str) -> String {
|
||||
if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::<String>()) } else { name.to_string() }
|
||||
}
|
||||
|
||||
pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 0.0, t.canvas);
|
||||
@@ -1526,18 +1606,23 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
}
|
||||
let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click());
|
||||
register(ui.ctx(), format!("film:{}", id.0), cr);
|
||||
let sel = Some(*id) == active;
|
||||
let state = app.session.selection.state_of(*id);
|
||||
let sel = state == lightcraft_engine::SelectionState::Active;
|
||||
let p = ui.painter();
|
||||
if sel {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected);
|
||||
let label = app.session.catalog.photo(*id).and_then(|ph| ph.label);
|
||||
let selected = state != lightcraft_engine::SelectionState::NotSelected;
|
||||
let base = if selected {
|
||||
t.cell_selected
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
|
||||
}
|
||||
t.cell_selected.gamma_multiply(0.6)
|
||||
} else {
|
||||
t.canvas
|
||||
};
|
||||
p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected));
|
||||
let names = app.ui.settings.film_names;
|
||||
if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) {
|
||||
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
|
||||
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim);
|
||||
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
|
||||
}
|
||||
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
||||
@@ -1549,6 +1634,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if sel {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
|
||||
} else if state == lightcraft_engine::SelectionState::Selected {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside);
|
||||
}
|
||||
if app.ui.settings.film_badges
|
||||
&& let Some(ph) = app.session.catalog.photo(*id)
|
||||
@@ -1608,6 +1695,33 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_geometry_tests {
|
||||
use super::{fit_rect, fit_texture_rect};
|
||||
use crate::state::Zoom;
|
||||
use egui::{Rect, pos2, vec2};
|
||||
|
||||
#[test]
|
||||
fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() {
|
||||
let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
|
||||
let image = fit_texture_rect(frame, [100, 200]);
|
||||
|
||||
assert!((image.width() / image.height() - 0.5).abs() < 1e-6);
|
||||
assert_eq!(image.center(), frame.center());
|
||||
assert!(frame.contains_rect(image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_preview_aspect_fits_detail_area_without_stretching() {
|
||||
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
|
||||
let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5));
|
||||
|
||||
assert!((image.width() / image.height() - 0.5).abs() < 1e-6);
|
||||
assert_eq!(image.size(), vec2(100.0, 200.0));
|
||||
assert_eq!(image.center(), area.center());
|
||||
}
|
||||
}
|
||||
|
||||
/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto.
|
||||
/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle.
|
||||
/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards.
|
||||
@@ -1648,3 +1762,20 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::film_label;
|
||||
|
||||
#[test]
|
||||
fn film_labels_cut_on_characters_not_bytes() {
|
||||
// the name from #266: byte 13 falls inside '限'
|
||||
assert_eq!(film_label("202407層三限定訂閱圖(4)"), "202407層三限定訂閱圖…");
|
||||
assert_eq!(film_label("写真"), "写真");
|
||||
// exactly 14 characters (42 bytes) is shown whole
|
||||
assert_eq!(film_label("一二三四五六七八九十一二三四"), "一二三四五六七八九十一二三四");
|
||||
assert_eq!(film_label("IMG_20240712_153012"), "IMG_20240712_…");
|
||||
assert_eq!(film_label("DSC_0001"), "DSC_0001");
|
||||
assert_eq!(film_label(""), "");
|
||||
}
|
||||
}
|
||||
@@ -97,19 +97,28 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Merge { opts } => opts.title(),
|
||||
Dialog::Settings { .. } => "Settings",
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library",
|
||||
Dialog::RemoveFolder { .. } => "Remove Folder from Library",
|
||||
Dialog::SamModel { .. } => "Download the SAM 3 Model?",
|
||||
Dialog::About => "About LightCraft",
|
||||
Dialog::Shortcuts => "Keyboard Shortcuts",
|
||||
}
|
||||
.to_string();
|
||||
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12));
|
||||
let shown = egui::Window::new(crate::i18n::tr(&title)).id(egui::Id::new("lightcraft-dialog"))
|
||||
// The import review can be resized (its photo grid takes the room); it keeps a window id of its
|
||||
// own so the size it is given doesn't carry over to the other dialogs. Its content scrolls
|
||||
// rather than growing the window when the options below the grid get taller (e.g. Copy).
|
||||
let import = matches!(dlg, Dialog::Import { .. });
|
||||
let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" });
|
||||
let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.resizable(import)
|
||||
.vscroll(import)
|
||||
.resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r })
|
||||
.frame(frame)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.default_width(match dlg {
|
||||
Dialog::Import { .. } => 760.0,
|
||||
Dialog::Import { .. } => crate::import::DIALOG_SIZE[0],
|
||||
Dialog::SmartRules { .. } => 680.0,
|
||||
Dialog::AllMetadata { .. } => 620.0,
|
||||
_ => 380.0,
|
||||
@@ -220,7 +229,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Dialog::SmartRules { name, rules, .. } => {
|
||||
Dialog::SmartRules { id, name, rules } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:smartName", r.rect);
|
||||
ui.add_space(6.0);
|
||||
@@ -228,7 +237,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
crate::panels::rules_editor::edit(ui, rules, "rules", 0);
|
||||
});
|
||||
let problems = rules.problems();
|
||||
let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), ..Default::default() };
|
||||
// the folder an album made from a folder view carries is not in the editor, but it counts
|
||||
let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone()));
|
||||
let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() };
|
||||
let n = if problems.is_empty() { app.session.catalog.query(&f, &Default::default()).len() } else { 0 };
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
@@ -710,6 +721,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
|
||||
}
|
||||
Dialog::RemoveFolder { name, count, path, disk } => {
|
||||
ui.label(crate::i18n::tr_format!(
|
||||
"Remove “{name}” and its {count} photo{} from the library?",
|
||||
if *count == 1 { "" } else { "s" },
|
||||
name = name,
|
||||
count = count
|
||||
));
|
||||
ui.label(egui::RichText::new(path.as_str()).color(t.text_dim));
|
||||
let scope = if *disk { "All the photos imported from this disk are included." } else { "That includes the photos in the folders inside it." };
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(scope)).color(t.text_dim));
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("They move to Recently Deleted and can be restored; no file on disk is touched."))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::About => {
|
||||
ui.set_min_width(680.0);
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
@@ -759,12 +785,23 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
ui.label(crate::i18n::tr(label));
|
||||
let grid_pick = *id == "panel.presets" && crate::shortcuts::library_grid(app);
|
||||
ui.label(crate::i18n::tr(if grid_pick { "Flag as Pick" } else { label }));
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.label(egui::RichText::new(if grid_pick { "photo.flag" } else { id }).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for sc in ["0–5", "Shift+0–5"] {
|
||||
ui.label(crate::i18n::tr("Set Rating"));
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new("photo.rate").color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(crate::i18n::tr("Set Color Label"));
|
||||
ui.label("6–9");
|
||||
ui.label(egui::RichText::new("photo.label").color(t.text_dim));
|
||||
ui.end_row();
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut {
|
||||
ui.label(crate::i18n::tr(c.label));
|
||||
@@ -817,6 +854,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
Dialog::Merge { .. } => "Merge",
|
||||
Dialog::ConfirmDelete { .. } => "Delete",
|
||||
Dialog::RemoveFolder { .. } => "Remove",
|
||||
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
|
||||
Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "",
|
||||
Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again",
|
||||
@@ -836,6 +874,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if import {
|
||||
crate::import::note_dialog_bottom(ui);
|
||||
}
|
||||
});
|
||||
if let Some(w) = shown {
|
||||
ctx.move_to_top(w.response.layer_id);
|
||||
@@ -1030,6 +1071,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
|
||||
Dialog::Import { opts } => crate::import::start(app, opts),
|
||||
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
|
||||
Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})),
|
||||
Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim));
|
||||
let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
|
||||
let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label);
|
||||
let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color");
|
||||
let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label);
|
||||
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() {
|
||||
@@ -345,36 +345,33 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and
|
||||
/// Browse….
|
||||
fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
use lightcraft_engine::presets::{PROFILES, profile, profile_groups};
|
||||
use lightcraft_engine::presets::{PROFILES, profile_groups};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_min_width(200.0);
|
||||
let cur = d.profile.id.as_str();
|
||||
// (clicked, hovered)
|
||||
let mut pick: (Option<&'static str>, Option<&'static str>) = (None, None);
|
||||
let item = |ui: &mut egui::Ui,
|
||||
key: &str,
|
||||
p: &'static lightcraft_engine::presets::ProfileInfo,
|
||||
pick: &mut (Option<&'static str>, Option<&'static str>)| {
|
||||
let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name));
|
||||
register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect);
|
||||
let mut pick: (Option<String>, Option<String>) = (None, None);
|
||||
let item = |ui: &mut egui::Ui, key: &str, id: &str, name: &str, pick: &mut (Option<String>, Option<String>)| {
|
||||
let r = ui.selectable_label(id == cur, crate::i18n::profile_label(id, name));
|
||||
register(ui.ctx(), format!("profileMenu:{key}:{id}"), r.rect);
|
||||
if r.clicked() {
|
||||
pick.0 = Some(p.id);
|
||||
pick.0 = Some(id.to_string());
|
||||
}
|
||||
if r.hovered() {
|
||||
pick.1 = Some(p.id);
|
||||
pick.1 = Some(id.to_string());
|
||||
}
|
||||
};
|
||||
let heading = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim));
|
||||
};
|
||||
for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] {
|
||||
let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect();
|
||||
let list: Vec<_> = ids.iter().filter_map(|id| app.session.profile_info(id).map(|(name, _)| (id.as_str(), name))).collect();
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
heading(ui, title);
|
||||
for p in list {
|
||||
item(ui, key, p, &mut pick);
|
||||
for (id, name) in list {
|
||||
item(ui, key, id, name, &mut pick);
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
@@ -382,23 +379,37 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
|
||||
let r = ui.menu_button(crate::i18n::tr(g), |ui| {
|
||||
ui.set_min_width(170.0);
|
||||
for p in PROFILES.iter().filter(|p| p.group == g) {
|
||||
item(ui, "group", p, &mut pick);
|
||||
item(ui, "group", p.id, p.name, &mut pick);
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect);
|
||||
}
|
||||
// imported LUT profile groups
|
||||
let mut lut_groups: Vec<String> = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect();
|
||||
lut_groups.sort_unstable();
|
||||
lut_groups.dedup();
|
||||
for g in &lut_groups {
|
||||
// a folder's name as it is, and its own widget id even when it matches a built-in group
|
||||
let r = ui.menu_button(g.as_str(), |ui| {
|
||||
ui.set_min_width(170.0);
|
||||
for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) {
|
||||
item(ui, "group", &p.id, &p.name, &mut pick);
|
||||
}
|
||||
});
|
||||
register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect);
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(p) = profile(cur) {
|
||||
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
|
||||
if let Some((name, _)) = app.session.profile_info(cur) {
|
||||
let fav = app.session.profile_favorites.iter().any(|f| f == cur);
|
||||
let label = if fav {
|
||||
crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name))
|
||||
crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::profile_label(cur, name))
|
||||
} else {
|
||||
crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name))
|
||||
crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::profile_label(cur, name))
|
||||
};
|
||||
let r = ui.button(label);
|
||||
register(ui.ctx(), "profileMenu:toggleFavorite", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("profile.favorite", json!({"id": p.id}));
|
||||
let _ = app.run("profile.favorite", json!({"id": cur}));
|
||||
}
|
||||
}
|
||||
let r = ui.button(crate::i18n::tr("Browse…"));
|
||||
@@ -407,13 +418,15 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
|
||||
let _ = app.run("panel.profiles", json!({}));
|
||||
}
|
||||
// resting on a profile previews it in the loupe
|
||||
if let Some(p) = pick.1.and_then(profile)
|
||||
&& p.id != cur
|
||||
if let Some(hovered_id) = &pick.1
|
||||
&& hovered_id != cur
|
||||
&& let Some((name, _)) = app.session.profile_info(hovered_id)
|
||||
{
|
||||
let mut s = d.clone();
|
||||
s.profile.id = p.id.to_string();
|
||||
s.profile.id = hovered_id.clone();
|
||||
s.profile.amount = 100.0;
|
||||
app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
|
||||
app.hover_preview =
|
||||
Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(hovered_id, name)), settings: s });
|
||||
}
|
||||
if let Some(id) = pick.0 {
|
||||
let _ = app.run("develop.profile", json!({"id": id}));
|
||||
|
||||
@@ -16,15 +16,7 @@ pub const HEIGHT: f32 = 40.0;
|
||||
const WIDE: f32 = 1240.0;
|
||||
|
||||
/// Colour-label swatches (UI colours, our own choice).
|
||||
pub fn label_color(l: ColorLabel) -> Color32 {
|
||||
match l {
|
||||
ColorLabel::Red => Color32::from_rgb(222, 72, 72),
|
||||
ColorLabel::Yellow => Color32::from_rgb(232, 196, 58),
|
||||
ColorLabel::Green => Color32::from_rgb(88, 176, 92),
|
||||
ColorLabel::Blue => Color32::from_rgb(72, 130, 222),
|
||||
ColorLabel::Purple => Color32::from_rgb(158, 100, 210),
|
||||
}
|
||||
}
|
||||
pub use crate::theme::label_color;
|
||||
|
||||
fn filter(app: &mut LightcraftApp, patch: Value) {
|
||||
let _ = app.run("library.filter", patch);
|
||||
|
||||
@@ -150,9 +150,19 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
folder_header(app, ui, hr, &b, &ids, local, &cnt)
|
||||
}
|
||||
None => {
|
||||
let title = crate::i18n::source_label(app.session.source, &app.session.catalog);
|
||||
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
|
||||
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
|
||||
let full = crate::i18n::source_title(&app.session);
|
||||
let count_w = ui.painter().layout_no_wrap(cnt.clone(), t.font(12.5), t.text_dim).size().x;
|
||||
// the title gives way to the count: a long album or folder name is trimmed, in full on hover
|
||||
let room = (hr.width() - 20.0 - count_w - 36.0).max(0.0);
|
||||
let font = t.semibold(17.0);
|
||||
let title = crate::widgets::elide_head(&full, room, |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text).size().x);
|
||||
let title_rect = ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, font, t.text);
|
||||
let count_rect = ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
|
||||
crate::widgets::register(ui.ctx(), "grid:title", title_rect);
|
||||
crate::widgets::register(ui.ctx(), "grid:count", count_rect);
|
||||
if title != full {
|
||||
ui.interact(title_rect, ui.id().with("grid-title"), Sense::hover()).on_hover_text(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
super::chips::show(app, ui, &chips);
|
||||
@@ -164,11 +174,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if !chips.is_empty() {
|
||||
let body = match total {
|
||||
Some(n) if n > 0 => {
|
||||
crate::i18n::tr_format!(
|
||||
"{n} photos in {} are hidden by the filters above",
|
||||
crate::i18n::source_label(app.session.source, &app.session.catalog),
|
||||
n = n
|
||||
)
|
||||
crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", crate::i18n::source_title(&app.session), n = n)
|
||||
}
|
||||
_ => "Remove a filter above, or choose Clear all".to_string(),
|
||||
};
|
||||
@@ -471,7 +477,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
let resp = ui.interact(r, egui::Id::new(("cell", id.0)), Sense::click_and_drag());
|
||||
register(ui.ctx(), format!("thumb:{}", id.0), r);
|
||||
let selected = app.session.selection.contains(id);
|
||||
let state = app.session.selection.state_of(id);
|
||||
let selected = state != lightcraft_engine::SelectionState::NotSelected;
|
||||
// screen readers: the file, then rating / flag / label
|
||||
let mut spoken = photo.file_name.clone();
|
||||
if photo.rating > 0 {
|
||||
@@ -486,10 +493,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
spoken.push_str(&format!(", {} label", app.session.catalog.label_name(l)));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &spoken));
|
||||
let active = app.session.selection.active == Some(id);
|
||||
let active = state == lightcraft_engine::SelectionState::Active;
|
||||
let p = ui.painter();
|
||||
let img_rect = if square {
|
||||
p.rect_filled(r, 0.0, if selected { t.cell_selected } else { t.cell });
|
||||
let base = if selected { t.cell_selected } else { t.cell };
|
||||
p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected));
|
||||
Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0))
|
||||
} else {
|
||||
r
|
||||
@@ -499,12 +507,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
request_thumb(app, id, size, if onscreen { 10 } else { 5 });
|
||||
if let Some(tex) = app.renderer.thumb(id) {
|
||||
let [tw, th] = tex.size;
|
||||
let fit = if square {
|
||||
let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32);
|
||||
Rect::from_center_size(img_rect.center(), vec2(tw as f32 * s, th as f32 * s))
|
||||
} else {
|
||||
img_rect
|
||||
};
|
||||
// Preserve the decoded preview's ratio when it differs from the catalog dimensions.
|
||||
let fit = super::detail::fit_texture_rect(img_rect, [tw, th]);
|
||||
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if active {
|
||||
p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
@@ -514,6 +518,12 @@ 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));
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
@@ -552,9 +562,15 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
crate::state::GridBadges::Always => true,
|
||||
crate::state::GridBadges::Never => false,
|
||||
};
|
||||
let badge_bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom());
|
||||
if !square && photo.label.is_some() {
|
||||
// Justified photos fill the whole cell, so the label shows as a translucent tinted footer
|
||||
// over the photo's bottom edge (opaque, it would hide 24 px of every labelled photo).
|
||||
p.rect_filled(badge_bar, 0.0, crate::theme::label_background(t.cell, photo.label, selected).gamma_multiply(0.7));
|
||||
}
|
||||
if show_badges {
|
||||
let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom());
|
||||
if resp.hovered() || selected {
|
||||
let bar = badge_bar;
|
||||
if (resp.hovered() || selected) && (square || photo.label.is_none()) {
|
||||
p.rect_filled(bar, 0.0, Color32::from_black_alpha(120));
|
||||
}
|
||||
let mut x = bar.left() + 6.0;
|
||||
@@ -904,7 +920,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if missing && ui.button(crate::i18n::tr("Locate Missing File…")).clicked() {
|
||||
let _ = app.run("photo.locate", json!({}));
|
||||
}
|
||||
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr("Show in Finder"))).clicked() {
|
||||
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr(crate::menus::reveal_label()))).clicked() {
|
||||
let _ = app.run("app.showInFinder", json!({}));
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Export…")).clicked() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The left "My Photos" panel: library sources, albums tree, and date groups.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use lightcraft_catalog::{Album, AlbumId, KeywordNode};
|
||||
use lightcraft_catalog::{Album, AlbumId, FolderNode, KeywordNode};
|
||||
use lightcraft_engine::LibrarySource;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -10,6 +10,24 @@ use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register};
|
||||
|
||||
/// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are
|
||||
/// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it
|
||||
/// exceeds the panel. One frame behind, which needs no second layout pass.
|
||||
pub(crate) fn content_width(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::new("left-content-width"))).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The most a row asks for its name: longer ones are cut when there is no more room.
|
||||
const MAX_NAME_NEED: f32 = 140.0;
|
||||
|
||||
/// A row says how wide it needs to be.
|
||||
fn note_width(ui: &egui::Ui, w: f32) {
|
||||
ui.data_mut(|d| {
|
||||
let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::new("left-content-width-next"), || 0.0);
|
||||
*m = m.max(w);
|
||||
});
|
||||
}
|
||||
|
||||
fn row(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
@@ -19,19 +37,40 @@ fn row(
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
indent: f32,
|
||||
) -> egui::Response {
|
||||
row_named(app, ui, id, icon, label, None, count, selected, indent)
|
||||
}
|
||||
|
||||
/// [`row`] whose spoken name is `spoken` when the painted `label` is a shortened form of it.
|
||||
fn row_named(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
spoken: Option<&str>,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
indent: f32,
|
||||
) -> egui::Response {
|
||||
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click());
|
||||
register(ui.ctx(), format!("source:{id}"), r);
|
||||
let said = spoken.unwrap_or(label);
|
||||
let name = match count {
|
||||
Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = label, n = n),
|
||||
None => label.to_string(),
|
||||
Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n),
|
||||
None => said.to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name));
|
||||
let inner = r.shrink2(vec2(8.0, 0.0));
|
||||
// what is visible of the row: when the sidebar is scrolled sideways its bars and counts end at
|
||||
// the panel's edge, not at the end of the (wider) content
|
||||
let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX);
|
||||
let edge = r.right().min(visible_right);
|
||||
let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom()));
|
||||
if selected {
|
||||
ui.painter().rect_filled(inner, 4.0, t.canvas);
|
||||
register(ui.ctx(), format!("highlight:{id}"), inner);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
@@ -41,16 +80,29 @@ fn row(
|
||||
icon,
|
||||
if selected { t.text } else { t.icon },
|
||||
);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 42.0 + indent, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
t.font(13.5),
|
||||
if selected { t.text } else { t.text_label },
|
||||
);
|
||||
if let Some(n) = count.filter(|_| app.ui.show_counts) {
|
||||
ui.painter().text(pos2(r.right() - 18.0, r.center().y), Align2::RIGHT_CENTER, n.to_string(), t.font(12.5), t.text_dim);
|
||||
let font = t.font(13.5);
|
||||
let color = if selected { t.text } else { t.text_label };
|
||||
let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim));
|
||||
let label_left = r.left() + 42.0 + indent;
|
||||
let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x);
|
||||
// the name gives way to the count: cut with an ellipsis, in full on hover
|
||||
let room = count_left - 8.0 - label_left;
|
||||
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x;
|
||||
let full_w = measure(label);
|
||||
let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) };
|
||||
let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color);
|
||||
register(ui.ctx(), format!("label:{id}"), label_rect);
|
||||
let resp = if shown != label { resp.on_hover_text(label) } else { resp };
|
||||
// a row asks for room for its name up to a share of a panel, so one very long name does not
|
||||
// make everything scroll: depth does that
|
||||
let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0;
|
||||
if let Some(galley) = count_galley {
|
||||
let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size());
|
||||
needed += rect.width() + 16.0;
|
||||
register(ui.ctx(), format!("count:{id}"), rect);
|
||||
ui.painter().galley(rect.min, galley, t.text_dim);
|
||||
}
|
||||
note_width(ui, needed);
|
||||
let _ = app;
|
||||
resp
|
||||
}
|
||||
@@ -90,7 +142,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
|
||||
let counts = app.caches.counts(&app.session.catalog);
|
||||
let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted);
|
||||
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
// rows are as wide as the widest one needs (last frame), at least the panel
|
||||
let wide = viewport.width().max(content_width(ui.ctx()));
|
||||
ui.set_min_width(wide);
|
||||
ui.set_max_width(wide);
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32);
|
||||
// where the visible part of the content ends (screen x), for what stays at the edge
|
||||
d.insert_temp(egui::Id::new("left-visible-right"), ui.cursor().left() + viewport.max.x);
|
||||
});
|
||||
let src = app.session.source;
|
||||
for (id, icon, label, count, s) in [
|
||||
("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All),
|
||||
@@ -111,9 +172,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.add_space(10.0);
|
||||
// Albums header
|
||||
let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums");
|
||||
// the + stays at the visible edge when the sidebar is scrolled sideways
|
||||
let plus_right = (ar.left() + viewport.max.x).min(ar.right());
|
||||
let mut hdr = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom()))
|
||||
.max_rect(Rect::from_min_max(pos2(plus_right - 50.0, ar.top()), pos2(plus_right, ar.bottom())))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
|
||||
@@ -158,11 +221,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
folders_section(app, ui);
|
||||
keywords_section(app, ui);
|
||||
ui.add_space(10.0);
|
||||
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": "recentlyDeleted"}));
|
||||
}
|
||||
// what the rows asked for becomes next frame's width
|
||||
let next = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-content-width-next"))).unwrap_or(0.0);
|
||||
if (next - content_width(ui.ctx())).abs() > 0.5 {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(w) = resized {
|
||||
@@ -521,7 +591,7 @@ fn folder_tree(
|
||||
}
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
|
||||
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
@@ -715,6 +785,172 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig
|
||||
false
|
||||
}
|
||||
|
||||
/// "Folders": where on disk the library's photos were imported from, with photo counts (see
|
||||
/// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a
|
||||
/// Local folder: its photos and those of the folders inside it fill the grid. The triangle opens
|
||||
/// a level. Only folders holding imported photos are
|
||||
/// listed; every folder on disk is under Local.
|
||||
fn folders_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let tree = app.caches.folder_tree(&app.session.catalog);
|
||||
if tree.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if sidebar_section_header(app, ui, "folders", "Folders").1 {
|
||||
reveal_chosen(app, ui, &tree);
|
||||
folder_rows(app, ui, &tree, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows
|
||||
/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes.
|
||||
fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
|
||||
let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder);
|
||||
let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty());
|
||||
let seen = egui::Id::new("libfolder-revealed");
|
||||
let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key);
|
||||
if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) {
|
||||
return;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(seen, now));
|
||||
let Some(chosen) = chosen else { return };
|
||||
fn open_above(ui: &egui::Ui, nodes: &[FolderNode], chosen: &str) {
|
||||
for n in nodes {
|
||||
if lightcraft_catalog::query::folder_within(chosen, &n.path) && !same_folder(chosen, &n.path) {
|
||||
let key = lightcraft_catalog::query::folder_key(&n.path);
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new(("libfolder-open", key)), true));
|
||||
open_above(ui, &n.children, chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
open_above(ui, tree, &chosen);
|
||||
}
|
||||
|
||||
fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for n in nodes {
|
||||
let key = lightcraft_catalog::query::folder_key(&n.path);
|
||||
let open_id = egui::Id::new(("libfolder-open", key.clone()));
|
||||
// a disk starts open, and so does a folder that holds nothing itself and leads to one
|
||||
// folder (`Users` → `me`): the first row where the library branches is what you look for
|
||||
let leads_on = n.own == 0 && n.children.len() == 1;
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(n.volume || leads_on);
|
||||
// a row whose path would cover other disks' photos too only opens and closes
|
||||
let selectable = n.selectable;
|
||||
let sel = selectable
|
||||
&& app.session.source == LibrarySource::LibraryFolder
|
||||
&& app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path));
|
||||
let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() };
|
||||
let resp = row_named(app, ui, &format!("libfolder:{}", n.path), Icon::Folder, &name, Some(&name), Some(n.count), sel, indent);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
// disclosure triangle left of the icon
|
||||
let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y);
|
||||
let tri = Rect::from_center_size(c, vec2(14.0, 14.0));
|
||||
let tr = ui.interact(tri, egui::Id::new(("libfolder-tri", key)), Sense::click());
|
||||
register(ui.ctx(), format!("libraryFolderToggle:{}", n.path), tri);
|
||||
let col = if tr.hovered() { t.text } else { t.text_dim };
|
||||
let pts = if open {
|
||||
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
|
||||
} else {
|
||||
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE));
|
||||
toggled = tr.clicked();
|
||||
tr.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") })
|
||||
});
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
row_menu(app, &tr, n);
|
||||
}
|
||||
let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) };
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() && !toggled {
|
||||
if selectable {
|
||||
// a source like an album or a Local folder: it replaces what the grid showed
|
||||
let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path}));
|
||||
} else {
|
||||
toggled = true;
|
||||
}
|
||||
}
|
||||
if toggled {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
row_menu(app, &resp, n);
|
||||
if open && !n.children.is_empty() {
|
||||
folder_rows(app, ui, &n.children, indent + 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the
|
||||
/// startup disk.
|
||||
fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
if !n.volume {
|
||||
folder_menu_for_library(app, resp, n);
|
||||
} else if n.path != "/" {
|
||||
resp.context_menu(|ui| {
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove Disk from Library…"))
|
||||
.on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched"))
|
||||
.clicked()
|
||||
{
|
||||
app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: n.path.clone(), name: n.name.clone(), count: n.count, disk: true });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The context menu of a folder row: the folder's disk actions (the same as Local's, photos
|
||||
/// follow) and taking its photos out of the library.
|
||||
fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
let path = n.path.as_str();
|
||||
let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
// what dialogs call it: the last two names, so same-named folders are told apart
|
||||
let label = lightcraft_catalog::folders::folder_label(path);
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(crate::i18n::tr("Rename Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
|
||||
title: crate::i18n::tr_format!("Rename “{name}”", name = label),
|
||||
hint: "Folder name (renamed on disk; its photos follow)".into(),
|
||||
value: name.clone(),
|
||||
command: "folder.rename".into(),
|
||||
params: json!({"path": path}),
|
||||
key: "name".into(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() {
|
||||
let into = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(into) = into {
|
||||
match app.run("folder.move", json!({"path": path, "into": into})) {
|
||||
Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::i18n::tr("Remove from Library…"))
|
||||
.on_hover_text(crate::i18n::tr("Moves the photos imported from this folder to Recently Deleted; no file is touched"))
|
||||
.clicked()
|
||||
{
|
||||
app.ui.dialog = Some(crate::state::Dialog::RemoveFolder { path: path.to_string(), name: label.clone(), count: n.count, disk: false });
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click
|
||||
/// filters the grid by the keyword (children included), the triangle opens a level, and the
|
||||
/// context menu renames, merges or deletes the keyword across the library.
|
||||
@@ -787,6 +1023,35 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::local_places;
|
||||
use crate::widgets::elide_head;
|
||||
|
||||
/// Width = characters, so a limit of 12 is "12 characters".
|
||||
fn fit(text: &str, max: usize) -> String {
|
||||
elide_head(text, max as f32, |s| s.chars().count() as f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_that_fits_is_left_alone() {
|
||||
assert_eq!(fit("/Users/me/Pictures", 18), "/Users/me/Pictures");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_path_loses_its_leading_folders_not_its_end() {
|
||||
assert_eq!(fit("/Users/me/Pictures/Lightroom", 20), "…/Pictures/Lightroom");
|
||||
assert_eq!(fit("/Users/me/Pictures/Lightroom", 12), "…/Lightroom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_name_too_long_is_cut_at_the_end() {
|
||||
assert_eq!(fit("2024-summer-holiday", 8), "2024-su…");
|
||||
assert_eq!(fit("2024-summer-holiday", 1), "…");
|
||||
assert_eq!(fit("2024-summer-holiday", 0), "…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_names_are_cut_on_character_boundaries() {
|
||||
assert_eq!(fit("/写真/夏休み旅行の記録", 6), "夏休み旅行…");
|
||||
}
|
||||
|
||||
fn names(v: &[(String, String)]) -> Vec<&str> {
|
||||
v.iter().map(|(n, _)| n.as_str()).collect()
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn resizable_side(
|
||||
/// The HUD toast at the bottom centre of the canvas.
|
||||
pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let Some((text, until)) = app.ui.toast.clone() else { return };
|
||||
let Some((text, until, label)) = app.ui.toast.clone() else { return };
|
||||
if now > until {
|
||||
app.ui.toast = None;
|
||||
return;
|
||||
@@ -74,11 +74,13 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast")));
|
||||
// long messages (a save warning, the web build's notices) wrap within the canvas
|
||||
let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0);
|
||||
let galley = painter.layout(text, t.font(14.0), t.text.gamma_multiply(fade), wrap);
|
||||
let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text));
|
||||
let foreground = foreground.gamma_multiply(fade);
|
||||
let galley = painter.layout(text, t.font(14.0), foreground, wrap);
|
||||
let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0));
|
||||
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
|
||||
painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8));
|
||||
painter.galley(r.center() - galley.size() / 2.0, galley, t.text);
|
||||
painter.rect_filled(r, 6.0, background.gamma_multiply(fade));
|
||||
painter.galley(r.center() - galley.size() / 2.0, galley, foreground);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
use lightcraft_engine::presets::{PROFILES, ProfileInfo, profile, profile_groups};
|
||||
use lightcraft_engine::presets::{PROFILES, profile_groups};
|
||||
use serde_json::json;
|
||||
|
||||
use super::presets::cover_uv;
|
||||
@@ -33,34 +33,66 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
divider(ui);
|
||||
// the applied profile and its amount
|
||||
let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
|
||||
let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
|
||||
let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color");
|
||||
ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim);
|
||||
ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label);
|
||||
ui.painter().text(
|
||||
pos2(cr.left() + 84.0, cr.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::profile_label(&d.profile.id, name),
|
||||
t.font(14.0),
|
||||
t.text_label,
|
||||
);
|
||||
if d.profile.id != "lc.color" {
|
||||
super::edit::control(app, ui, &d, "profile.amount", true);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
divider(ui);
|
||||
let mut sections: Vec<(&str, &str, Vec<&'static ProfileInfo>)> = Vec::new();
|
||||
let favs: Vec<&'static ProfileInfo> = app.session.profile_favorites.iter().filter_map(|f| profile(f)).collect();
|
||||
// (title, cell tag, imported LUT folder, profiles)
|
||||
let mut sections: Vec<(&str, &str, bool, Vec<(String, String, String)>)> = Vec::new();
|
||||
let favs: Vec<(String, String, String)> = app
|
||||
.session
|
||||
.profile_favorites
|
||||
.iter()
|
||||
.filter_map(|f| app.session.profile_info(f).map(|(name, group)| (f.clone(), name.to_string(), group.to_string())))
|
||||
.collect();
|
||||
if !favs.is_empty() {
|
||||
sections.push(("Favorites", "fav", favs));
|
||||
sections.push(("Favorites", "fav", false, favs));
|
||||
}
|
||||
for g in profile_groups() {
|
||||
sections.push((g, "", PROFILES.iter().filter(|p| p.group == g).collect()));
|
||||
sections.push((
|
||||
g,
|
||||
"",
|
||||
false,
|
||||
PROFILES.iter().filter(|p| p.group == g).map(|p| (p.id.to_string(), p.name.to_string(), p.group.to_string())).collect(),
|
||||
));
|
||||
}
|
||||
for (title, tag, list) in sections {
|
||||
let open_id = egui::Id::new(("profile-group", title));
|
||||
// imported LUT profile groups
|
||||
let mut lut_groups: Vec<String> = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect();
|
||||
lut_groups.sort_unstable();
|
||||
lut_groups.dedup();
|
||||
for g in &lut_groups {
|
||||
let profiles = app.session.lut_profiles.iter().filter(|p| &p.group == g).map(|p| (p.id.clone(), p.name.clone(), p.group.clone())).collect();
|
||||
sections.push((g.as_str(), "", true, profiles));
|
||||
}
|
||||
for (title, tag, imported, list) in sections {
|
||||
// an imported folder named like a built-in group still opens and closes on its own
|
||||
let open_id = egui::Id::new(("profile-group", imported, title));
|
||||
let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
register(ui.ctx(), format!("profileGroup:{title}"), r);
|
||||
register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r);
|
||||
paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)),
|
||||
if open { Icon::ChevronDown } else { Icon::ChevronRight },
|
||||
t.text_label,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label);
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 36.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::builtin_label(title, !imported),
|
||||
t.semibold(13.0),
|
||||
t.text_label,
|
||||
);
|
||||
ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
ui.data_mut(|m| m.insert_temp(open_id, !open));
|
||||
@@ -87,34 +119,47 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if app.hover_preview.is_none()
|
||||
&& let Some((saved_id, saved_profile_id)) = ui.data(|m| m.get_temp::<(PhotoId, String)>(last_hover_id))
|
||||
&& saved_id == id
|
||||
&& let Some(p) = profile(&saved_profile_id)
|
||||
&& let Some((p_name, _)) = app.session.profile_info(&saved_profile_id)
|
||||
{
|
||||
let s = with_profile(&d, p);
|
||||
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
|
||||
let s = with_profile_id(&d, &saved_profile_id);
|
||||
app.hover_preview = Some(HoverPreview {
|
||||
label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(&saved_profile_id, p_name)),
|
||||
settings: s,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// `d` with profile `p` (at 100 % unless it is the applied one).
|
||||
fn with_profile(d: &DevelopSettings, p: &ProfileInfo) -> DevelopSettings {
|
||||
/// `d` with profile `profile_id` (at 100 % unless it is the applied one).
|
||||
fn with_profile_id(d: &DevelopSettings, profile_id: &str) -> DevelopSettings {
|
||||
let mut s = d.clone();
|
||||
if s.profile.id != p.id {
|
||||
s.profile.id = p.id.to_string();
|
||||
if s.profile.id != profile_id {
|
||||
s.profile.id = profile_id.to_string();
|
||||
s.profile.amount = 100.0;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId, tag: &str, p: &'static ProfileInfo, cell: Rect, th: f32) {
|
||||
fn cell_ui(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
d: &DevelopSettings,
|
||||
id: PhotoId,
|
||||
tag: &str,
|
||||
p: &(String, String, String),
|
||||
cell: Rect,
|
||||
th: f32,
|
||||
) {
|
||||
let (p_id, p_name, p_group) = (p.0.as_str(), p.1.as_str(), p.2.as_str());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = if tag.is_empty() { format!("profileCell:{}", p.id) } else { format!("profileCell:{tag}:{}", p.id) };
|
||||
let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") };
|
||||
let resp = ui.interact(cell, egui::Id::new(&key), Sense::click());
|
||||
register(ui.ctx(), key, cell);
|
||||
let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th));
|
||||
let s = with_profile(d, p);
|
||||
let s = with_profile_id(d, p_id);
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(thumb, 3.0, t.canvas);
|
||||
if let Some(job) = app.session.variant_job(id, &s, THUMB_EDGE)
|
||||
@@ -122,33 +167,44 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
|
||||
{
|
||||
painter.image(tex.tex.id(), thumb, cover_uv(thumb, tex.size), Color32::WHITE);
|
||||
}
|
||||
let applied = d.profile.id == p.id;
|
||||
let applied = d.profile.id == p_id;
|
||||
if applied {
|
||||
painter.rect_stroke(thumb.expand(1.0), 4.0, Stroke::new(2.0, t.pick), egui::StrokeKind::Outside);
|
||||
} else if resp.hovered() {
|
||||
painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
|
||||
}
|
||||
let name_color = if applied { t.text } else { t.text_label };
|
||||
painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color);
|
||||
painter.text(
|
||||
pos2(cell.left() + 2.0, thumb.bottom() + 12.0),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::profile_label(p_id, p_name),
|
||||
t.font(12.0),
|
||||
name_color,
|
||||
);
|
||||
// favourite star (always shown when set, on hover otherwise)
|
||||
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
|
||||
let fav = app.session.profile_favorites.iter().any(|f| f == p_id);
|
||||
let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0));
|
||||
register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p.id), star);
|
||||
register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p_id), star);
|
||||
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 });
|
||||
}
|
||||
if resp.hovered() && !applied && !over_star {
|
||||
app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() });
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string())));
|
||||
app.hover_preview =
|
||||
Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() });
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string())));
|
||||
}
|
||||
let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group)));
|
||||
let resp = resp.on_hover_text(format!(
|
||||
"{} ({})",
|
||||
crate::i18n::profile_label(p_id, p_name),
|
||||
crate::i18n::builtin_label(p_group, lightcraft_engine::presets::profile(p_id).is_some())
|
||||
));
|
||||
if resp.clicked() {
|
||||
if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) {
|
||||
let _ = app.run("profile.favorite", json!({"id": p.id}));
|
||||
let _ = app.run("profile.favorite", json!({"id": p_id}));
|
||||
} else if !applied {
|
||||
let _ = app.run("develop.profile", json!({"id": p.id, "amount": 100}));
|
||||
let _ = app.run("develop.profile", json!({"id": p_id, "amount": 100}));
|
||||
ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,33 +62,99 @@ fn padded(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 6 }).show(ui, add);
|
||||
}
|
||||
|
||||
/// The aspect presets of the Crop panel: (menu label, `crop.aspect` value).
|
||||
const ASPECT_PRESETS: [(&str, &str); 10] = [
|
||||
("Free", "free"),
|
||||
("Original", "original"),
|
||||
("1 × 1", "1x1"),
|
||||
("4 × 5 / 8 × 10", "4x5"),
|
||||
("8.5 × 11", "8.5x11"),
|
||||
("5 × 7", "5x7"),
|
||||
("2 × 3 / 4 × 6", "2x3"),
|
||||
("4 × 3", "4x3"),
|
||||
("16 × 9", "16x9"),
|
||||
("16 × 10", "16x10"),
|
||||
];
|
||||
|
||||
/// What the aspect button says for the stored lock (`aspect` = width × 100, height × 100):
|
||||
/// a preset's name when the ratio matches one in either orientation, "Original" for the photo's own
|
||||
/// shape, otherwise the ratio as `1.37 : 1`.
|
||||
fn aspect_label(aspect: Option<(u32, u32)>, original: Option<f64>) -> String {
|
||||
let Some((w, h)) = aspect else { return "Free".into() };
|
||||
if w == 0 || h == 0 {
|
||||
return "Free".into();
|
||||
}
|
||||
let r = w as f64 / h as f64;
|
||||
let is = |q: f64| q.is_finite() && q > 0.0 && ((r - q).abs() / q < 0.004 || (1.0 / r - q).abs() / q < 0.004);
|
||||
if original.is_some_and(is) {
|
||||
return "Original".into();
|
||||
}
|
||||
for (label, key) in ASPECT_PRESETS {
|
||||
if let Some((x, y)) = key.split_once('x')
|
||||
&& let (Ok(x), Ok(y)) = (x.parse::<f64>(), y.parse::<f64>())
|
||||
&& is(x / y)
|
||||
{
|
||||
return label.to_string();
|
||||
}
|
||||
}
|
||||
format!("{:.2} : 1", r.max(1.0 / r))
|
||||
}
|
||||
|
||||
/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`).
|
||||
fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let key = egui::Id::new("crop-custom-aspect");
|
||||
let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into()));
|
||||
let mut apply = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::i18n::tr("Custom"));
|
||||
let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0));
|
||||
register(ui.ctx(), "field:cropCustomW", a.rect);
|
||||
ui.label("×");
|
||||
let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0));
|
||||
register(ui.ctx(), "field:cropCustomH", b.rect);
|
||||
let go = text_button(ui, "cropCustomApply", "Apply", false);
|
||||
apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)));
|
||||
});
|
||||
if apply {
|
||||
// Accept `3`, `2.5` and `3,5`; anything else (zero, negative, text, huge) is ignored.
|
||||
let num = |t: &str| t.trim().replace(',', ".").parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 1000.0);
|
||||
if let (Some(x), Some(y)) = (num(&w), num(&h))
|
||||
&& (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&(x / y))
|
||||
{
|
||||
let _ = app.run("crop.aspect", json!({"aspect": [x, y]}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, (w, h)));
|
||||
}
|
||||
|
||||
fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
header(ui, "Crop");
|
||||
padded(ui, |ui| {
|
||||
let original = app.session.catalog.photo(id).map(|p| {
|
||||
let (w, h) = (p.width.max(1) as f64, p.height.max(1) as f64);
|
||||
if d.orientation.swaps_axes() { h / w } else { w / h }
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::i18n::tr("Aspect Ratio"));
|
||||
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
|
||||
let r = ui.add(egui::Button::new(crate::i18n::tr(&cur)).frame(false));
|
||||
register(ui.ctx(), "button:cropAspect", r.rect);
|
||||
let cur = aspect_label(d.crop.aspect, original);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text);
|
||||
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for (label, a) in [
|
||||
("Free", "free"),
|
||||
("Original", "original"),
|
||||
("1 × 1", "1x1"),
|
||||
("4 × 5 / 8 × 10", "4x5"),
|
||||
("8.5 × 11", "8.5x11"),
|
||||
("5 × 7", "5x7"),
|
||||
("2 × 3 / 4 × 6", "2x3"),
|
||||
("4 × 3", "4x3"),
|
||||
("16 × 9", "16x9"),
|
||||
("16 × 10", "16x10"),
|
||||
] {
|
||||
for (label, a) in ASPECT_PRESETS {
|
||||
if ui.button(crate::i18n::tr(label)).clicked() {
|
||||
let _ = app.run("crop.aspect", json!({"aspect": a}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
custom_aspect(app, ui);
|
||||
});
|
||||
let locked = d.crop.aspect.is_some();
|
||||
if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() {
|
||||
let _ = app.run("crop.aspect", json!({"aspect": "toggle"}));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -482,7 +548,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate());
|
||||
if crate::menus::ui_enabled(app, "app.showInFinder") {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, "Show in Finder").clicked() {
|
||||
if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label())
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.showInFinder", json!({}));
|
||||
}
|
||||
});
|
||||
@@ -969,6 +1037,23 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Feature: the aspect button tells what is locked
|
||||
#[test]
|
||||
fn aspect_label_names_presets_in_either_orientation() {
|
||||
assert_eq!(super::aspect_label(None, Some(1.5)), "Free");
|
||||
assert_eq!(super::aspect_label(Some((1600, 900)), None), "16 × 9");
|
||||
assert_eq!(super::aspect_label(Some((900, 1600)), None), "16 × 9", "portrait 16:9");
|
||||
assert_eq!(super::aspect_label(Some((400, 500)), None), "4 × 5 / 8 × 10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_label_prefers_original_over_a_coinciding_preset_and_never_shows_pixel_sizes() {
|
||||
assert_eq!(super::aspect_label(Some((300, 200)), Some(1.5)), "Original");
|
||||
assert_eq!(super::aspect_label(Some((600_000, 400_000)), Some(1.5)), "Original");
|
||||
assert_eq!(super::aspect_label(Some((137, 100)), Some(1.5)), "1.37 : 1");
|
||||
assert_eq!(super::aspect_label(Some((0, 5)), None), "Free");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_times() {
|
||||
assert_eq!(super::short_time("2026-09-30T12:00:05"), "Sep 30, 2026, 12:00 PM");
|
||||
|
||||
@@ -17,6 +17,10 @@ pub const ALIASES: &[(&str, &str, &str)] = &[
|
||||
("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#),
|
||||
("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#),
|
||||
("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#),
|
||||
("Shift+6", "photo.label", r#"{"label": "red", "advance": true}"#),
|
||||
("Shift+7", "photo.label", r#"{"label": "yellow", "advance": true}"#),
|
||||
("Shift+8", "photo.label", r#"{"label": "green", "advance": true}"#),
|
||||
("Shift+9", "photo.label", r#"{"label": "blue", "advance": true}"#),
|
||||
// Shift+[ / Shift+] arrive as { / } on most layouts
|
||||
("Shift+{", "brush.featherLess", "{}"),
|
||||
("Shift+}", "brush.featherMore", "{}"),
|
||||
@@ -67,12 +71,21 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
||||
|
||||
fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
|
||||
i.events.iter().any(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => {
|
||||
egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => {
|
||||
// `Ctrl` is the physical Control key (on macOS distinct from Cmd; elsewhere Cmd = Ctrl);
|
||||
// "Delete" (⌫ = Backspace) also matches forward-delete
|
||||
let ctrl_ok = if m.ctrl { modifiers.ctrl } else { !modifiers.ctrl || modifiers.command };
|
||||
let cmd_ok = m.ctrl || modifiers.command == m.command;
|
||||
(*key == k || (k == Key::Backspace && *key == Key::Delete)) && ctrl_ok && cmd_ok && modifiers.shift == m.shift && modifiers.alt == m.alt
|
||||
// winit can report shifted digits as punctuation (Shift+1 = `!`). Recognize the
|
||||
// physical number key for culling, without changing layout-aware letter shortcuts.
|
||||
let shifted_digit = m.shift
|
||||
&& *physical_key == Some(k)
|
||||
&& matches!(k, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9);
|
||||
(*key == k || shifted_digit || (k == Key::Backspace && *key == Key::Delete))
|
||||
&& ctrl_ok
|
||||
&& cmd_ok
|
||||
&& modifiers.shift == m.shift
|
||||
&& modifiers.alt == m.alt
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
@@ -174,6 +187,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
app.toast(ctx, label);
|
||||
} else if let Some(l) = f.strip_prefix("label:") {
|
||||
cull(app, "photo.label", json!({"label": l}), false);
|
||||
} else if f == "panel.presets" && library_grid(app) {
|
||||
// Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere.
|
||||
cull(app, "photo.flag", json!({"flag": "pick"}), true);
|
||||
} else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
|
||||
// S in a grid: expand / collapse the stack (Lightroom's Library binding)
|
||||
let _ = app.run("stack.toggle", json!({}));
|
||||
@@ -276,9 +292,221 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn library_grid(app: &LightcraftApp) -> bool {
|
||||
matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
fn library() -> crate::headless::Headless {
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
let mut session = lightcraft_engine::Session::new();
|
||||
for id in 1..=4 {
|
||||
let photo = Photo::new(
|
||||
PhotoId(id),
|
||||
Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") },
|
||||
&format!("{id}.jpg"),
|
||||
"JPEG",
|
||||
100,
|
||||
100,
|
||||
"2026-10-08",
|
||||
);
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||||
}
|
||||
session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap();
|
||||
session.execute("library.select", &json!({"ids": [1]})).unwrap();
|
||||
let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() });
|
||||
let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.app.ui.view = crate::state::ViewMode::PhotoGrid;
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) {
|
||||
let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_rating_labels_flags_and_undo() {
|
||||
use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
|
||||
for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] {
|
||||
let mut h = library();
|
||||
h.app.ui.view = view;
|
||||
h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap();
|
||||
for rating in [1, 2, 3, 4, 5, 0] {
|
||||
key(&mut h, &rating.to_string(), false);
|
||||
for id in [1, 2] {
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating);
|
||||
}
|
||||
}
|
||||
for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] {
|
||||
key(&mut h, k, false);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label));
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label));
|
||||
}
|
||||
for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] {
|
||||
key(&mut h, k, false);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag);
|
||||
}
|
||||
h.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject);
|
||||
h.app.session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None);
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_culling_keys_advance_exactly_once() {
|
||||
use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
|
||||
for auto in [false, true] {
|
||||
for (k, rating, label, flag) in [
|
||||
("0", 0, None, Flag::Pick),
|
||||
("5", 5, None, Flag::Pick),
|
||||
("6", 3, Some(ColorLabel::Red), Flag::Pick),
|
||||
("7", 3, Some(ColorLabel::Yellow), Flag::Pick),
|
||||
("8", 3, Some(ColorLabel::Green), Flag::Pick),
|
||||
("9", 3, Some(ColorLabel::Blue), Flag::Pick),
|
||||
("P", 3, None, Flag::Pick),
|
||||
("X", 3, None, Flag::Reject),
|
||||
("U", 3, None, Flag::None),
|
||||
] {
|
||||
let mut h = library();
|
||||
h.app.ui.auto_advance = auto;
|
||||
h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap();
|
||||
h.app.session.execute("photo.pick", &json!({})).unwrap();
|
||||
key(&mut h, k, true);
|
||||
let p = h.app.session.catalog.photo(PhotoId(1)).unwrap();
|
||||
assert_eq!(p.rating, rating, "Shift+{k}");
|
||||
assert_eq!(p.label, label, "Shift+{k}");
|
||||
assert_eq!(p.flag, flag, "Shift+{k}");
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}");
|
||||
}
|
||||
}
|
||||
let mut h = library();
|
||||
h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap();
|
||||
key(&mut h, "6", true);
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_pick_preserves_presets_outside_the_library_grids() {
|
||||
let mut h = library();
|
||||
h.app.ui.view = crate::state::ViewMode::SquareGrid;
|
||||
key(&mut h, "P", true);
|
||||
assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2)));
|
||||
assert!(!h.app.ui.presets, "grid Shift+P must not open Presets");
|
||||
assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid);
|
||||
h.app.ui.view = crate::state::ViewMode::Detail;
|
||||
key(&mut h, "P", true);
|
||||
assert!(h.app.ui.presets);
|
||||
assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_label_targets_only_candidate_in_compare() {
|
||||
use lightcraft_catalog::{ColorLabel, PhotoId};
|
||||
let mut h = library();
|
||||
h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap();
|
||||
crate::panels::compare::enter_compare(&mut h.app).unwrap();
|
||||
key(&mut h, "6", true);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red));
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_culling_keys_auto_advance_once() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
for k in ["0", "5", "6", "9", "P", "X", "U"] {
|
||||
let mut h = library();
|
||||
h.app.ui.auto_advance = true;
|
||||
key(&mut h, k, false);
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_label_actions_show_feedback_only_after_success() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut h = library();
|
||||
for shift in [false, true] {
|
||||
for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] {
|
||||
h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap();
|
||||
h.app.ui.toast = None;
|
||||
key(&mut h, k, shift);
|
||||
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str()));
|
||||
assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label);
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 })));
|
||||
}
|
||||
}
|
||||
// Menus and the Info-panel swatches dispatch the same command, including purple/clear.
|
||||
h.app.run("photo.label", json!({"label": "purple"})).unwrap();
|
||||
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label"));
|
||||
h.app.run("photo.label", json!({"label": "none"})).unwrap();
|
||||
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared"));
|
||||
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback");
|
||||
h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap();
|
||||
key(&mut h, "6", false);
|
||||
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label"));
|
||||
// Native menu callbacks run before logic() catches up to the current frame's clock.
|
||||
let now = h.view.ctx.input(|i| i.time);
|
||||
h.app.last_time = now - 10.0;
|
||||
h.app.run("photo.label", json!({"label": "blue"})).unwrap();
|
||||
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap");
|
||||
h.app.ui.toast = None;
|
||||
assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err());
|
||||
assert!(h.app.ui.toast.is_none(), "failed actions must not announce success");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_number_punctuation_rates_and_advances() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut h = library();
|
||||
// A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1.
|
||||
for pressed in [true, false] {
|
||||
h.app.synthetic.push(egui::Event::Key {
|
||||
key: Key::Exclamationmark,
|
||||
physical_key: Some(Key::Num1),
|
||||
pressed,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::SHIFT,
|
||||
});
|
||||
}
|
||||
h.step();
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1);
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_keys_yield_to_search_and_crop_context() {
|
||||
use lightcraft_catalog::{Flag, PhotoId};
|
||||
let mut h = library();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for k in ["5", "6", "P", "X", "U"] {
|
||||
key(&mut h, k, false);
|
||||
key(&mut h, k, true);
|
||||
}
|
||||
let p = h.app.session.catalog.photo(PhotoId(1)).unwrap();
|
||||
assert_eq!(p.rating, 0);
|
||||
assert_eq!(p.label, None);
|
||||
assert_eq!(p.flag, Flag::None);
|
||||
assert_eq!(h.app.session.active(), Some(PhotoId(1)));
|
||||
|
||||
let mut h = library();
|
||||
h.app.ui.view = crate::state::ViewMode::Detail;
|
||||
h.app.ui.right = crate::state::RightPanel::Crop;
|
||||
key(&mut h, "X", false);
|
||||
assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_means_the_backspace_key() {
|
||||
assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace)));
|
||||
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user