From fa12afed188eb858a4e119e58705bee5d7cad26e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 11 Aug 2026 21:12:01 +0200 Subject: [PATCH] Keep originals on this device, by pin and by use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in `image_cache`, which the previous commit's "On this device" filter read but nothing wrote. Also carries in-flight work that shared these files: the Android TLS root store, the settings page, and a regenerated traceability report. # Two populations, deliberately separate An original is kept here for one of two reasons, and conflating them produces the exact failure the feature exists to prevent. **Pinned** originals were asked for. Pinning a collection before a trip is a promise, so pinned rows are never evicted and never counted against the budget — a cap that could silently delete a pinned trip would make pinning worthless, because it could not be relied on without checking. **Passively cached** originals are a side effect of working: develop already downloads the whole file, so keeping it costs no bandwidth and saves the entire transfer next time. This population is what the budget bounds, evicted least-recently-used, because it otherwise grows until a day of culling fills a disk. Sharing one budget would let a large pin starve the passive cache, or let browsing evict a pin. They are separate. # What was built `dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned — and writes the bytes; deciding to download stays with the caller, which is what keeps a crate with no network out of the network's business. Files are written to a temporary and renamed, so a dropped connection cannot leave a truncated file recorded as a complete original. They are named by image id, not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different photographs, and a flat cache keyed on the name would serve one for the other. `spawn_full_fetch` became read-through. A hit is a disk read; a miss stores what it downloads and enforces the budget. A cache that cannot be opened is a miss, not a failure to open the photograph. Pinning writes intent — `tier_desired` — without downloading, so the button responds immediately, and `spawn_pin_fetch` fills it in sequentially afterwards. Sequential because these are tens of megabytes each: the lanes that make the thumbnail sweep fast buy little against one connection's bandwidth and cost a great deal of memory. A pin interrupted by a lost connection resumes from where it stopped. Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot answer for an image whose rule was deleted. A v4 catalog migrates in place; existing rows default to unpinned, the safe direction. The budget and "keep opened originals" come from the settings page rather than a constant, and are applied at startup rather than only on change — a cache capped at 2 GB last session would otherwise spend this one filling to the default. Turning off keeping leaves what is already cached readable: those bytes are paid for, and refusing them would re-download images sitting right there, including pinned ones. Also removes a doubled `#[test]` introduced in the previous commit. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 26 +- Cargo.toml | 16 +- .../android/AndroidManifest.xml | 11 + core/dr-catalog/src/cache.rs | 782 ++++++++++++++++++ core/dr-catalog/src/lib.rs | 2 + core/dr-catalog/src/schema.rs | 81 +- core/dr-sync-nextcloud/src/lib.rs | 18 + core/dr-types/Cargo.toml | 6 + core/dr-types/src/lib.rs | 5 + core/dr-types/src/selector.rs | 1 - core/dr-types/src/settings.rs | 602 ++++++++++++++ docs/traceability.md | 72 +- ui/dr-ui/Cargo.toml | 3 + ui/dr-ui/src/launch_ui.rs | 35 +- ui/dr-ui/src/lib.rs | 102 ++- ui/dr-ui/src/library.rs | 248 ++++++ ui/dr-ui/src/library_ui.rs | 512 +++++++++++- ui/dr-ui/src/settings_store.rs | 233 ++++++ ui/dr-ui/src/settings_ui.rs | 562 +++++++++++++ ui/dr-ui/ui/app.slint | 152 +++- ui/dr-ui/ui/library.slint | 68 ++ ui/dr-ui/ui/settings.slint | 581 +++++++++++++ 22 files changed, 4032 insertions(+), 86 deletions(-) create mode 100644 core/dr-catalog/src/cache.rs create mode 100644 core/dr-types/src/settings.rs create mode 100644 ui/dr-ui/src/settings_store.rs create mode 100644 ui/dr-ui/src/settings_ui.rs create mode 100644 ui/dr-ui/ui/settings.slint diff --git a/Cargo.lock b/Cargo.lock index fc62925..a75d983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1452,6 +1452,7 @@ name = "dr-types" version = "0.1.0" dependencies = [ "serde", + "serde_json", "thiserror 2.0.20", ] @@ -1479,6 +1480,7 @@ dependencies = [ "serde_norway", "slint", "slint-build", + "thiserror 2.0.20", "tokio", "wgpu", ] @@ -5202,9 +5204,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" dependencies = [ "base64", "bytes", @@ -5237,6 +5239,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -5440,13 +5443,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.22.4", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -6960,9 +6963,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.5.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -7151,6 +7154,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index e4f27d7..5461121 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,10 +64,18 @@ pollster = "0.4" # cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid. # ring is pure Rust apart from a small asm core that does build under the NDK. # -# Note this still pulls rustls-platform-verifier, which crashes on Android -# unless initialised from Kotlin (spike S3). D7 records `tls_certs_only` plus -# webpki-roots as the escape hatch. -reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream", "json"] } +# `rustls-tls-webpki-roots-no-provider` rather than plain `rustls-no-provider`: +# the latter verifies against rustls-platform-verifier, which reaches the +# Android trust store over JNI and panics mid-handshake unless initialised from +# Java first — the crash D7 predicted and spike S3 exists to resolve properly. +# The panic surfaces inside tokio, which catches task panics itself, so it +# reaches the UI as a worker that stopped rather than as an error. +# +# webpki-roots is the escape hatch D7 records: a root store compiled into the +# binary, no JNI, identical on both platforms. The trade is real and belongs in +# S3's scope — user-installed and enterprise CAs are not consulted, and the +# roots go stale with the release rather than with the OS. +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "webpki-roots", "stream", "json"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } quick-xml = "0.41" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } diff --git a/apps/darkroom-android/android/AndroidManifest.xml b/apps/darkroom-android/android/AndroidManifest.xml index 200a45b..93d06f6 100644 --- a/apps/darkroom-android/android/AndroidManifest.xml +++ b/apps/darkroom-android/android/AndroidManifest.xml @@ -11,6 +11,17 @@ + + + + + diff --git a/core/dr-catalog/src/cache.rs b/core/dr-catalog/src/cache.rs new file mode 100644 index 0000000..da02c10 --- /dev/null +++ b/core/dr-catalog/src/cache.rs @@ -0,0 +1,782 @@ +//! TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4 +//! Which originals are kept on this device, and which may be evicted. +//! +//! # Two populations, one table +//! +//! An original ends up here two ways, and conflating them produces exactly the +//! failure the whole feature exists to prevent. +//! +//! **Pinned** originals were asked for. A user pins a collection before a trip +//! and expects those photographs to be there when there is no connection — +//! that is a promise, so pinned rows are never evicted and never counted +//! against the budget. A cap that could silently delete a pinned trip would +//! make pinning worthless, because the user could not rely on it without +//! checking. +//! +//! **Passively cached** originals are a side effect of working: opening an +//! image in develop downloads it, so keeping the bytes costs nothing extra and +//! saves the whole transfer next time. This population is bounded by +//! [`Budget`] and evicted least-recently-used, because it grows without limit +//! otherwise — a day of culling would fill a disk. +//! +//! The two budgets are separate rather than shared. Sharing them means a large +//! pin starves the passive cache, or worse, that browsing evicts a pin. +//! +//! # What this module does and does not own +//! +//! It owns the *bookkeeping*: which images are held, at what tier, how large, +//! when last used, and which are pinned. The bytes are files under a cache +//! directory, and [`store`](Cache::store) writes them; but deciding to +//! download something is the caller's business, because that needs a network +//! and this crate has none. +//! +//! # Why `tier_actual` is the truth +//! +//! `tier_desired` is what a pin asks for; `tier_actual` is what is on disk. +//! Only the second answers "can this be opened right now", which is the +//! question offline mode asks (FR-CAT-9). A pinned image whose download has +//! not run yet is precisely the one that would fail, so it must not report as +//! available. + +use std::path::{Path, PathBuf}; + +use dr_types::{ImageId, Tier}; +use rusqlite::Connection; + +use crate::error::CatalogError; + +/// Default ceiling for passively cached originals. +/// +/// 1 GB holds roughly 30 full-frame RAWs — a working session's worth, which is +/// what this cache is for. It is deliberately modest: the passive cache is a +/// convenience that should not quietly consume a disk, and a user who wants +/// more kept is better served by pinning, which says so explicitly and is not +/// subject to eviction at all. +pub const DEFAULT_BUDGET_BYTES: u64 = 1024 * 1024 * 1024; + +/// How much disk the passive cache may use. +/// +/// A newtype rather than a bare `u64` so a byte count cannot be passed where a +/// budget belongs, and to give the "unlimited" case a name — some users have a +/// large disk and would rather never re-download. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Budget(Option); + +impl Default for Budget { + fn default() -> Self { + Self::bytes(DEFAULT_BUDGET_BYTES) + } +} + +impl Budget { + pub fn bytes(n: u64) -> Self { + Self(Some(n)) + } + + /// No ceiling: nothing is ever evicted for space. + pub fn unlimited() -> Self { + Self(None) + } + + pub fn limit(self) -> Option { + self.0 + } + + /// How much must be freed to fit `used` within this budget. + fn overage(self, used: u64) -> u64 { + self.0.map_or(0, |cap| used.saturating_sub(cap)) + } +} + +/// What is held for one image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub image: ImageId, + /// What is actually on disk. + pub tier: Tier, + /// What a pin has asked for, which may be ahead of `tier`. + pub desired: Tier, + pub bytes: u64, + /// Unix seconds, or `None` if never read back since being stored. + pub last_used: Option, + pub pinned: bool, + /// Path relative to the cache directory. + pub path: Option, +} + +/// How the cache is currently filled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Usage { + /// Bytes held by pinned originals. Not subject to the budget. + pub pinned_bytes: u64, + /// Bytes held by passively cached originals. What the budget bounds. + pub passive_bytes: u64, + pub pinned_count: usize, + pub passive_count: usize, +} + +impl Usage { + pub fn total_bytes(self) -> u64 { + self.pinned_bytes + self.passive_bytes + } +} + +/// The on-disk cache of originals, rooted at a directory. +pub struct Cache { + dir: PathBuf, + budget: Budget, +} + +impl Cache { + /// Open a cache rooted at `dir`, creating it if needed. + pub fn open(dir: &Path, budget: Budget) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?; + Ok(Self { + dir: dir.to_path_buf(), + budget, + }) + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn budget(&self) -> Budget { + self.budget + } + + /// Absolute path for a cached original. + /// + /// Named by image id rather than by the remote filename: two folders on + /// the server may hold `IMG_0001.CR2`, and a flat cache keyed on the name + /// would have them overwrite each other. The extension is preserved so the + /// decoder's format probe sees what it expects. + fn relative_path(image: ImageId, source_ref: &str) -> String { + let ext = source_ref + .rsplit_once('.') + .map(|(_, e)| e.to_ascii_lowercase()) + .filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())) + .unwrap_or_else(|| "bin".to_string()); + format!("{}.{ext}", image.0) + } + + /// Store an original's bytes and record it. + /// + /// `pinned` says which population this belongs to. Storing an image that + /// is already present updates it rather than duplicating — the same + /// photograph opened twice is one cache entry, and the second store simply + /// refreshes the bytes and the timestamp. + /// + /// Does **not** evict. The caller runs [`enforce`](Self::enforce) once it + /// has finished storing, so a batch of downloads is trimmed once rather + /// than after every file. + pub fn store( + &self, + conn: &Connection, + image: ImageId, + source_ref: &str, + bytes: &[u8], + pinned: bool, + now: i64, + ) -> Result<(), CatalogError> { + let rel = Self::relative_path(image, source_ref); + let abs = self.dir.join(&rel); + + // Written to a temporary and renamed, so a crash or a dropped + // connection mid-write cannot leave a truncated file that the catalog + // records as a complete original — which would then fail to decode + // with no indication that the *cache* was at fault rather than the + // photograph. + let tmp = abs.with_extension("partial"); + std::fs::write(&tmp, bytes) + .map_err(|e| CatalogError::Io(format!("writing {}: {e}", tmp.display())))?; + std::fs::rename(&tmp, &abs) + .map_err(|e| CatalogError::Io(format!("renaming {}: {e}", abs.display())))?; + + // `pinned` is OR-ed rather than assigned: an image that was already + // pinned must not be demoted to evictable because it happened to be + // opened in develop, which is a passive store. + conn.execute( + "INSERT INTO image_cache + (image_id, tier_actual, tier_desired, bytes, last_used, pinned, path) + VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(image_id) DO UPDATE SET + tier_actual = ?2, + tier_desired = max(tier_desired, ?2), + bytes = ?3, + last_used = ?4, + pinned = max(pinned, ?5), + path = ?6", + rusqlite::params![ + image.0 as i64, + Tier::Original.stored(), + bytes.len() as i64, + now, + i64::from(pinned), + rel, + ], + )?; + Ok(()) + } + + /// Read a cached original back, if it is here. + /// + /// Touches `last_used`, which is what makes the eviction order reflect + /// actual use rather than download order. A read that finds the row but + /// not the file repairs the catalog rather than returning bytes it does + /// not have — the two can diverge if a user clears the directory by hand. + pub fn load( + &self, + conn: &Connection, + image: ImageId, + now: i64, + ) -> Result>, CatalogError> { + let path: Option = conn + .query_row( + "SELECT path FROM image_cache + WHERE image_id = ?1 AND tier_actual >= ?2", + rusqlite::params![image.0 as i64, Tier::Original.stored()], + |r| r.get(0), + ) + .ok() + .flatten(); + + let Some(rel) = path else { return Ok(None) }; + let abs = self.dir.join(&rel); + + match std::fs::read(&abs) { + Ok(bytes) => { + conn.execute( + "UPDATE image_cache SET last_used = ?2 WHERE image_id = ?1", + rusqlite::params![image.0 as i64, now], + )?; + Ok(Some(bytes)) + } + Err(e) => { + // The file is gone but the row says it is here. Believing the + // row would report the image as locally available for ever + // while every open failed. + log::debug!("cached original {} missing, forgetting it: {e}", abs.display()); + self.forget(conn, &[image])?; + Ok(None) + } + } + } + + /// Whether an image's original is on this device. + pub fn holds_original(&self, conn: &Connection, image: ImageId) -> bool { + conn.query_row( + "SELECT 1 FROM image_cache + WHERE image_id = ?1 AND tier_actual >= ?2", + rusqlite::params![image.0 as i64, Tier::Original.stored()], + |_| Ok(()), + ) + .is_ok() + } + + /// Mark images as pinned, so they are kept regardless of the budget. + /// + /// Pinning records the *intent* — `tier_desired` — without downloading + /// anything: the download needs a network, which belongs to the caller. + /// An image already cached passively becomes pinned in place, keeping its + /// bytes rather than re-fetching them. + pub fn pin(&self, conn: &Connection, images: &[ImageId]) -> Result { + self.set_pinned(conn, images, true) + } + + /// Release a pin, returning those images to the evictable population. + /// + /// The bytes stay until eviction needs the room. Deleting immediately + /// would make unpinning destructive, when it is meant only to withdraw a + /// guarantee. + pub fn unpin(&self, conn: &Connection, images: &[ImageId]) -> Result { + self.set_pinned(conn, images, false) + } + + fn set_pinned( + &self, + conn: &Connection, + images: &[ImageId], + pinned: bool, + ) -> Result { + if images.is_empty() { + return Ok(0); + } + let tx = conn.unchecked_transaction()?; + let mut n = 0; + for image in images { + n += tx.execute( + "INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned) + VALUES (?1, ?2, ?3, 0, ?4) + ON CONFLICT(image_id) DO UPDATE SET + pinned = ?4, + -- A pin raises the target; releasing one lowers it back to + -- whatever is actually held, so a released image is not + -- left permanently claiming it wants an original. + tier_desired = CASE WHEN ?4 = 1 THEN ?3 ELSE tier_actual END", + rusqlite::params![ + image.0 as i64, + Tier::Metadata.stored(), + Tier::Original.stored(), + i64::from(pinned), + ], + )?; + } + tx.commit()?; + Ok(n) + } + + /// Images a pin wants but which are not yet downloaded. + /// + /// The work list for whatever fetches originals. Ordered by id for a + /// stable, resumable sequence rather than an arbitrary one. + pub fn pending_pins(&self, conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT image_id FROM image_cache + WHERE pinned = 1 AND tier_actual < tier_desired + ORDER BY image_id", + )?; + let rows = stmt + .query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))? + .collect::, _>>()?; + Ok(rows) + } + + /// How full the cache is, split by population. + /// + /// Counts only rows that actually hold an original: a pin that has not + /// downloaded yet occupies no disk, and counting its intent would evict + /// real files to make room for bytes that do not exist. + pub fn usage(&self, conn: &Connection) -> Result { + let mut stmt = conn.prepare( + "SELECT pinned, count(*), coalesce(sum(bytes), 0) + FROM image_cache + WHERE tier_actual >= ?1 + GROUP BY pinned", + )?; + let mut usage = Usage::default(); + let rows = stmt.query_map(rusqlite::params![Tier::Original.stored()], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)) + })?; + for row in rows { + let (pinned, count, bytes) = row?; + if pinned == 1 { + usage.pinned_count = count as usize; + usage.pinned_bytes = bytes as u64; + } else { + usage.passive_count = count as usize; + usage.passive_bytes = bytes as u64; + } + } + Ok(usage) + } + + /// Evict least-recently-used passive entries until the budget is met. + /// + /// Returns how many images were dropped. Pinned entries are never + /// candidates, which is the guarantee that makes a pin worth making. + /// + /// A row whose file has already vanished is still dropped from the + /// catalog: it frees no disk, but leaving it would let a phantom entry + /// hold the cache permanently over budget and evict real files in its + /// place. + pub fn enforce(&self, conn: &Connection) -> Result { + let usage = self.usage(conn)?; + let mut over = self.budget.overage(usage.passive_bytes); + if over == 0 { + return Ok(0); + } + + // Oldest first. `last_used IS NULL` sorts first deliberately: a row + // that has never been read back is the least valuable thing here. + let mut stmt = conn.prepare( + "SELECT image_id, bytes, path FROM image_cache + WHERE pinned = 0 AND tier_actual >= ?1 + ORDER BY last_used IS NULL DESC, last_used ASC", + )?; + let candidates = stmt + .query_map(rusqlite::params![Tier::Original.stored()], |r| { + Ok(( + ImageId(r.get::<_, i64>(0)? as u64), + r.get::<_, i64>(1)? as u64, + r.get::<_, Option>(2)?, + )) + })? + .collect::, _>>()?; + + let mut evicted = Vec::new(); + for (image, bytes, path) in candidates { + if over == 0 { + break; + } + if let Some(rel) = path { + let abs = self.dir.join(rel); + if let Err(e) = std::fs::remove_file(&abs) { + // Already gone is the common case and not a failure; the + // row still has to go, or it accounts for space nothing + // occupies. + log::debug!("evicting {}: {e}", abs.display()); + } + } + over = over.saturating_sub(bytes); + evicted.push(image); + } + + let n = evicted.len(); + self.forget(conn, &evicted)?; + Ok(n) + } + + /// Drop cache rows, without touching files. + /// + /// The row is reduced to `Metadata` rather than deleted, so a pin recorded + /// against it survives: unpinning is the only thing that should clear a + /// pin, and eviction of the bytes is not unpinning. + fn forget(&self, conn: &Connection, images: &[ImageId]) -> Result<(), CatalogError> { + if images.is_empty() { + return Ok(()); + } + let tx = conn.unchecked_transaction()?; + for image in images { + tx.execute( + "UPDATE image_cache + SET tier_actual = ?2, bytes = 0, path = NULL + WHERE image_id = ?1", + rusqlite::params![image.0 as i64, Tier::Metadata.stored()], + )?; + } + tx.commit()?; + Ok(()) + } + + /// Everything currently held, newest use first. For a cache management view. + pub fn entries(&self, conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT image_id, tier_actual, tier_desired, bytes, last_used, pinned, path + FROM image_cache + WHERE tier_actual >= ?1 + ORDER BY last_used IS NULL, last_used DESC", + )?; + let rows = stmt + .query_map(rusqlite::params![Tier::Original.stored()], |r| { + Ok(Entry { + image: ImageId(r.get::<_, i64>(0)? as u64), + tier: Tier::from_stored(r.get(1)?), + desired: Tier::from_stored(r.get(2)?), + bytes: r.get::<_, i64>(3)? as u64, + last_used: r.get(4)?, + pinned: r.get::<_, i64>(5)? == 1, + path: r.get(6)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + + /// Distinguishes concurrent fixtures. The harness runs tests in parallel, + /// and a shared directory would have one test's eviction delete another's + /// files. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + /// A scratch directory that is fresh for each call. + fn tempdir() -> PathBuf { + let base = std::env::temp_dir().join(format!( + "dr-cache-test-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + base + } + + /// A catalog with `n` images, and a cache in a scratch directory. + fn fixture(n: usize) -> (Catalog, Cache, PathBuf, Vec) { + fixture_with(n, Budget::bytes(1000)) + } + + fn fixture_with(n: usize, budget: Budget) -> (Catalog, Cache, PathBuf, Vec) { + let catalog = Catalog::in_memory().unwrap(); + catalog + .connection() + .execute( + "INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')", + [], + ) + .unwrap(); + + let mut ids = Vec::new(); + for i in 0..n { + catalog + .connection() + .execute( + "INSERT INTO images (root_id, source_ref, added_at) + VALUES (1, ?1, 0)", + rusqlite::params![format!("Photos/img{i:03}.CR2")], + ) + .unwrap(); + ids.push(ImageId( + catalog.connection().last_insert_rowid() as u64 + )); + } + let dir = tempdir(); + let cache = Cache::open(&dir, budget).unwrap(); + (catalog, cache, dir, ids) + } + + #[test] + fn a_stored_original_reads_back() { + let (cat, cache, _dir, ids) = fixture(1); + cache + .store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10) + .unwrap(); + + assert!(cache.holds_original(cat.connection(), ids[0])); + assert_eq!( + cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(), + Some(&b"raw bytes"[..]) + ); + } + + #[test] + fn an_image_never_stored_is_absent() { + let (cat, cache, _dir, ids) = fixture(1); + assert!(!cache.holds_original(cat.connection(), ids[0])); + assert_eq!(cache.load(cat.connection(), ids[0], 0).unwrap(), None); + } + + #[test] + fn eviction_takes_the_least_recently_used_first() { + let (cat, cache, _dir, ids) = fixture(3); + // 400 each against a 1000 budget: storing the third puts it 200 over. + let bytes = vec![0u8; 400]; + cache.store(cat.connection(), ids[0], "a.CR2", &bytes, false, 10).unwrap(); + cache.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20).unwrap(); + cache.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30).unwrap(); + + // Touch the oldest so it is no longer the least recently used. + cache.load(cat.connection(), ids[0], 40).unwrap(); + + assert_eq!(cache.enforce(cat.connection()).unwrap(), 1); + // ids[1] was the stalest by the time eviction ran. + assert!(!cache.holds_original(cat.connection(), ids[1])); + assert!(cache.holds_original(cat.connection(), ids[0])); + assert!(cache.holds_original(cat.connection(), ids[2])); + } + + #[test] + fn a_pinned_original_is_never_evicted() { + // The guarantee the whole feature rests on: a pinned trip must still + // be there after a day of browsing pushes the cache over its cap. + let (cat, cache, _dir, ids) = fixture(3); + let bytes = vec![0u8; 800]; + + cache.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10).unwrap(); + cache.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20).unwrap(); + cache.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30).unwrap(); + + cache.enforce(cat.connection()).unwrap(); + + assert!( + cache.holds_original(cat.connection(), ids[0]), + "the pinned original survives even though it is the oldest" + ); + } + + #[test] + fn pinned_bytes_do_not_count_against_the_budget() { + // Otherwise a large pin starves the passive cache into evicting + // everything, and browsing becomes uncacheable the moment a trip is + // pinned. + let (cat, cache, _dir, ids) = fixture(2); + cache + .store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 5000], true, 10) + .unwrap(); + cache + .store(cat.connection(), ids[1], "b.CR2", &vec![0u8; 500], false, 20) + .unwrap(); + + // Pinned use is far past the 1000 budget, but the passive 500 fits. + assert_eq!(cache.enforce(cat.connection()).unwrap(), 0); + assert!(cache.holds_original(cat.connection(), ids[1])); + + let usage = cache.usage(cat.connection()).unwrap(); + assert_eq!(usage.pinned_bytes, 5000); + assert_eq!(usage.passive_bytes, 500); + } + + #[test] + fn an_unlimited_budget_evicts_nothing() { + let (cat, cache, _dir, ids) = fixture_with(2, Budget::unlimited()); + for (i, id) in ids.iter().enumerate() { + cache + .store(cat.connection(), *id, "a.CR2", &vec![0u8; 100_000], false, i as i64) + .unwrap(); + } + assert_eq!(cache.enforce(cat.connection()).unwrap(), 0); + } + + #[test] + fn pinning_records_intent_without_bytes() { + // A pin is not a download: it says what should be here, and something + // with a network makes it so. + let (cat, cache, _dir, ids) = fixture(2); + cache.pin(cat.connection(), &ids).unwrap(); + + assert!(!cache.holds_original(cat.connection(), ids[0])); + assert_eq!(cache.pending_pins(cat.connection()).unwrap(), ids); + assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0); + } + + #[test] + fn a_downloaded_pin_stops_being_pending() { + let (cat, cache, _dir, ids) = fixture(2); + cache.pin(cat.connection(), &ids).unwrap(); + cache + .store(cat.connection(), ids[0], "a.CR2", b"bytes", true, 10) + .unwrap(); + + assert_eq!(cache.pending_pins(cat.connection()).unwrap(), vec![ids[1]]); + } + + #[test] + fn pinning_an_already_cached_image_keeps_its_bytes() { + // Re-downloading something already on disk because the user pinned it + // would be the most visible possible waste. + let (cat, cache, _dir, ids) = fixture(1); + cache + .store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10) + .unwrap(); + cache.pin(cat.connection(), &ids).unwrap(); + + assert!(cache.pending_pins(cat.connection()).unwrap().is_empty()); + assert_eq!( + cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(), + Some(&b"raw bytes"[..]) + ); + assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 9); + } + + #[test] + fn opening_a_pinned_image_does_not_unpin_it() { + // The develop path stores passively. If that overwrote `pinned`, then + // simply *looking at* a pinned photograph would silently make it + // evictable — the pin would decay through use. + let (cat, cache, _dir, ids) = fixture(1); + cache.pin(cat.connection(), &ids).unwrap(); + cache + .store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10) + .unwrap(); + + let entries = cache.entries(cat.connection()).unwrap(); + assert!(entries[0].pinned, "still pinned after a passive store"); + } + + #[test] + fn unpinning_keeps_the_bytes_but_makes_them_evictable() { + let (cat, cache, _dir, ids) = fixture(2); + cache + .store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 800], true, 10) + .unwrap(); + cache.unpin(cat.connection(), &ids[0..1]).unwrap(); + + // Still here — unpinning withdraws a guarantee, it does not delete. + assert!(cache.holds_original(cat.connection(), ids[0])); + + // But now it is a candidate. + cache + .store(cat.connection(), ids[1], "b.CR2", &vec![0u8; 800], false, 20) + .unwrap(); + assert_eq!(cache.enforce(cat.connection()).unwrap(), 1); + assert!(!cache.holds_original(cat.connection(), ids[0])); + } + + #[test] + fn a_missing_file_is_forgotten_rather_than_reported_present() { + // A user clearing the cache directory by hand must not leave every + // image claiming to be local while every open fails. + let (cat, cache, dir, ids) = fixture_with(1, Budget::bytes(1000)); + cache + .store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10) + .unwrap(); + + for entry in std::fs::read_dir(&dir).unwrap() { + std::fs::remove_file(entry.unwrap().path()).unwrap(); + } + + assert_eq!(cache.load(cat.connection(), ids[0], 20).unwrap(), None); + assert!(!cache.holds_original(cat.connection(), ids[0])); + } + + #[test] + fn storing_the_same_image_twice_is_one_entry() { + let (cat, cache, _dir, ids) = fixture(1); + cache.store(cat.connection(), ids[0], "a.CR2", b"first", false, 10).unwrap(); + cache.store(cat.connection(), ids[0], "a.CR2", b"second try", false, 20).unwrap(); + + let usage = cache.usage(cat.connection()).unwrap(); + assert_eq!(usage.passive_count, 1); + assert_eq!(usage.passive_bytes, 10, "the later size, not the sum"); + assert_eq!( + cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(), + Some(&b"second try"[..]) + ); + } + + #[test] + fn two_images_with_the_same_filename_do_not_collide() { + // `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different + // photographs; a cache keyed on the filename would serve one for the + // other, which is the worst failure this cache could have. + let (cat, cache, _dir, ids) = fixture(2); + cache + .store(cat.connection(), ids[0], "Photos/IMG_0001.CR2", b"first", false, 10) + .unwrap(); + cache + .store(cat.connection(), ids[1], "Trips/IMG_0001.CR2", b"second", false, 20) + .unwrap(); + + assert_eq!( + cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(), + Some(&b"first"[..]) + ); + assert_eq!( + cache.load(cat.connection(), ids[1], 30).unwrap().as_deref(), + Some(&b"second"[..]) + ); + } + + #[test] + fn eviction_stops_once_the_budget_is_met() { + // Evicting everything on a small overage would throw away a working + // set to reclaim a few bytes. + let (cat, cache, _dir, ids) = fixture(3); + for (i, id) in ids.iter().enumerate() { + cache + .store(cat.connection(), *id, "a.CR2", &vec![0u8; 400], false, i as i64) + .unwrap(); + } + // 1200 held against 1000: dropping one 400-byte entry suffices. + assert_eq!(cache.enforce(cat.connection()).unwrap(), 1); + assert_eq!(cache.usage(cat.connection()).unwrap().passive_count, 2); + } + + #[test] + fn an_extensionless_source_still_gets_a_path() { + let (cat, cache, _dir, ids) = fixture(1); + cache + .store(cat.connection(), ids[0], "Photos/no-extension", b"bytes", false, 10) + .unwrap(); + assert_eq!( + cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(), + Some(&b"bytes"[..]) + ); + } +} diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 6bea625..db77b74 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -30,6 +30,7 @@ use std::path::Path; use dr_types::{Availability, ImageId}; use rusqlite::Connection; +pub mod cache; pub mod collections; pub mod error; pub mod jobs; @@ -41,6 +42,7 @@ pub mod schema; pub mod sync; pub mod trash; +pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use collections::{Collection, CollectionKind, TreeRow}; pub use error::CatalogError; pub use jobs::{Job, JobKind, Priority}; diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index 675e5f7..bf66cf6 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 4; +pub const SCHEMA_VERSION: i64 = 5; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -60,6 +60,12 @@ pub fn migrate(conn: &Connection) -> Result { tx.pragma_update(None, "user_version", 4)?; tx.commit()?; } + if from < 5 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V5)?; + tx.pragma_update(None, "user_version", 5)?; + tx.commit()?; + } Ok(from) } @@ -220,6 +226,34 @@ fn stem_of(path: &str) -> &str { } } +const V5: &str = r#" +-- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4 +-- Offline availability: what is kept, why it is kept, and where it lives. +-- +-- `pinned` separates a promise from a convenience, and the distinction has to +-- be a *column* rather than something inferred from `pinned_by_rule`. A pin is +-- the user saying "this collection comes with me"; a passively cached original +-- is the app noticing they opened something. Only the second is evictable, so +-- the eviction query has to be able to ask the question directly — and it has +-- to keep answering correctly for an image whose pinning rule was since +-- deleted, which `pinned_by_rule` alone cannot do because it is +-- ON DELETE SET NULL. +ALTER TABLE image_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0; + +-- Where the cached original actually is, relative to the cache directory. +-- Relative rather than absolute: the library moves between machines and +-- between an app sandbox and a user directory, and an absolute path baked in +-- at download time would break on every one of those. +ALTER TABLE image_cache ADD COLUMN path TEXT; + +-- Eviction reads exactly this: unpinned rows, oldest use first. Partial on +-- `pinned = 0` because pinned rows are never candidates and including them +-- would make the index proportional to the whole library rather than to the +-- passive cache. +CREATE INDEX image_cache_evictable ON image_cache(last_used) + WHERE pinned = 0; +"#; + const V4: &str = r#" -- TRACES: FR-CAT-15 -- Soft delete. A trashed image is a real file that has been *moved* to a trash @@ -596,6 +630,51 @@ mod tests { assert_eq!(backfilled(&c, "shadowed_by"), 1); } + #[test] + fn a_v4_catalog_gains_the_pinning_columns() { + // TRACES: FR-NC-6a + // An existing library must not have to be rescanned to gain offline + // pinning. The rows are already there; only the columns are new. + let c = mem(); + c.execute_batch(V1).unwrap(); + c.execute_batch(V2).unwrap(); + c.execute_batch(V3).unwrap(); + c.execute_batch(V4).unwrap(); + c.pragma_update(None, "user_version", 4).unwrap(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) + VALUES (7, 1, 'IMG_7.CR2', 0)", + [], + ) + .unwrap(); + // A cache row written before pinning existed. + c.execute( + "INSERT INTO image_cache(image_id, tier_actual, bytes) VALUES (7, 2, 100)", + [], + ) + .unwrap(); + + assert_eq!(migrate(&c).unwrap(), 4, "migrated from v4"); + + // The pre-existing row survives, and defaults to unpinned — the safe + // direction, since claiming a pin nobody made would exempt it from + // eviction for ever. + let (pinned, bytes): (i64, i64) = c + .query_row( + "SELECT pinned, bytes FROM image_cache WHERE image_id = 7", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(pinned, 0); + assert_eq!(bytes, 100, "the existing row is untouched"); + } + #[test] fn stems_ignore_directories_containing_dots() { assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1"); diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index e60193c..0c81c25 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -510,6 +510,24 @@ pub fn http_client(user_agent: &str) -> Result { install_crypto_provider(); reqwest::Client::builder() .user_agent(user_agent.to_string()) + // Verify against the roots compiled into the binary rather than the + // platform store — D7's escape hatch, and what makes TLS work on + // Android at all. + // + // Without this, reqwest builds a `rustls_platform_verifier::Verifier`, + // which reads Android's trust store over JNI and panics during the + // handshake unless Java initialised it first. The panic lands inside a + // tokio task, so tokio swallows it: the worker thread simply stops, the + // channel closes, and the UI reports a failure with no error and no log + // line to explain it. + // + // `tls_certs_only` is the flag reqwest branches on to skip the platform + // verifier entirely (see its ClientBuilder TLS setup); the webpki-roots + // feature supplies the roots it then uses. Spike S3 revisits this to + // honour user-installed and enterprise CAs, which bundled roots cannot. + // Empty: the flag is what matters, and the roots come from the + // webpki-roots feature rather than from certificates passed here. + .tls_certs_only(std::iter::empty()) .build() .map_err(|e| RemoteError::Network(e.to_string())) } diff --git a/core/dr-types/Cargo.toml b/core/dr-types/Cargo.toml index 97b2c69..07ae6f9 100644 --- a/core/dr-types/Cargo.toml +++ b/core/dr-types/Cargo.toml @@ -11,3 +11,9 @@ thiserror.workspace = true # sync'd cache rules, so the predicate language has to serialise. Derive-only: # no serde machinery leaks into the rest of `core/`. serde.workspace = true + +# Only the tests parse JSON here. Writing `settings.json` is `dr-ui`'s job, so +# a serialiser in this crate's dependencies would be paid for by every crate in +# `core/` to serve one test module. +[dev-dependencies] +serde_json.workspace = true diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 1fcb833..108f054 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -9,8 +9,13 @@ use std::fmt; use std::ops::Range; pub mod selector; +pub mod settings; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; +pub use settings::{ + CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportSettings, OutputSharpening, + Settings, SizingMode, +}; /// Identifies a granted library location — a directory on Linux, a persisted /// document tree on Android. diff --git a/core/dr-types/src/selector.rs b/core/dr-types/src/selector.rs index 325cf50..2d72c8b 100644 --- a/core/dr-types/src/selector.rs +++ b/core/dr-types/src/selector.rs @@ -232,7 +232,6 @@ mod tests { assert_eq!(found, vec![CollectionId(1), CollectionId(2)]); } - #[test] #[test] fn stored_tiers_round_trip() { // These integers are on disk. A reordering of the variants that broke diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs new file mode 100644 index 0000000..cf4f812 --- /dev/null +++ b/core/dr-types/src/settings.rs @@ -0,0 +1,602 @@ +//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1 +//! Device preferences: how much disk to spend, and what an export defaults to. +//! +//! # Why these live beside the session and not in the catalog +//! +//! The catalog is per-*library* and its index is shared between machines +//! (FR-NC-6): a finished scan is sync'd so a second device inherits it rather +//! than repeating hours of range fetches. Everything in this file is per-* +//! device* instead, and putting it in the catalog would carry it across that +//! sync to somewhere it is wrong. +//! +//! A cache ceiling is the clearest case. "Keep 8 GB of originals" is a +//! statement about *this* disk; following the library to a phone it becomes a +//! promise that device cannot keep. Export defaults are the same kind of fact +//! one step removed — the destination folder is a local path, and a template +//! naming a drive that exists on the desktop resolves to nothing on the +//! laptop. +//! +//! So this is a sibling of `sessions.json` in the platform config directory, +//! written by the same read-modify-rename discipline (see `SettingsStore` in +//! `dr-ui`). The split is deliberate rather than incidental: signing out must +//! not discard a cache budget, and clearing preferences must not revoke a +//! credential. +//! +//! # Why defaults are a `Default` impl and not a config file +//! +//! Every field here answers correctly with no configuration at all. A first +//! run has no settings file, and that is not an error state to be repaired — +//! it is the common case. Reading an absent or corrupt file therefore yields +//! [`Settings::default`], and every field carries `#[serde(default)]` so a +//! file written by an older build is missing fields rather than unparseable. +//! +//! # What is deliberately *not* here +//! +//! Export **presets** (FR-EXP-5) are named, plural, and applicable several at +//! once to produce multiple outputs per image. That is a library of documents, +//! not a preference, and it does not belong in a single-valued settings +//! record. What is here is the *default* an export dialogue opens on and a +//! new preset inherits — one value per field, which is what a settings page +//! can present and what FR-EXP-1..3 call configurable. + +use serde::{Deserialize, Serialize}; + +/// Everything the settings page edits. +/// +/// Grouped by the section it appears under rather than flattened, so a new +/// field lands in one obvious place and the JSON stays readable to anyone who +/// opens it by hand. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + pub cache: CacheSettings, + pub export: ExportSettings, +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +/// How much of this device's disk the app may spend. +/// +/// Two ceilings, because there are two populations with different guarantees +/// (see `dr_catalog::cache`): passively cached originals are a convenience and +/// are evicted least-recently-used, while pinned originals are a promise and +/// are never evicted. Only the first is bounded here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CacheSettings { + /// Ceiling for passively cached originals, in bytes. `None` is unlimited. + /// + /// Optional rather than a sentinel `0`: "unlimited" and "keep nothing" are + /// both meaningful settings and a zero cannot mean both. A user with a + /// large disk who would rather never re-download picks the first; a user + /// on a small SSD who only ever works online picks the second. + pub original_budget_bytes: Option, + + /// Ceiling for the thumbnail and preview shards, in bytes. `None` is + /// unlimited. + /// + /// Separate from the originals budget because the two fail differently. + /// Evicting a thumbnail costs one small re-fetch and the grid heals as it + /// scrolls; evicting an original costs a full RAW transfer before an image + /// can be opened at all. A shared budget would let a day of browsing + /// originals evict the thumbnails that make the library navigable, which + /// is the more valuable of the two per byte. + pub thumbnail_budget_bytes: Option, + + /// Whether opening an image in develop keeps its original on disk. + /// + /// On by default: the bytes were transferred anyway, so keeping them costs + /// no bandwidth and saves the whole transfer next time. Off is for metered + /// or small-disk devices, where the user would rather re-fetch than store. + pub keep_opened_originals: bool, +} + +/// 8 GB of passively cached originals — roughly 250 full-frame RAWs. +/// +/// Larger than `dr_catalog::cache::DEFAULT_BUDGET_BYTES`, and deliberately: +/// that constant is the floor a `Cache` falls back to when nobody has said +/// otherwise, whereas this is what a desktop install should actually run with. +/// A culling session covers hundreds of frames, and a cache that holds a +/// tenth of one re-downloads images the user is still moving between. +pub const DEFAULT_ORIGINAL_BUDGET_BYTES: u64 = 8 * 1024 * 1024 * 1024; + +/// 2 GB of thumbnails and previews. +/// +/// Sized against the library rather than the working set, because the grid can +/// scroll anywhere: this holds the derived pyramid for a library in the tens +/// of thousands of images, which is the point of having it at all. +pub const DEFAULT_THUMBNAIL_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +impl Default for CacheSettings { + fn default() -> Self { + Self { + original_budget_bytes: Some(DEFAULT_ORIGINAL_BUDGET_BYTES), + thumbnail_budget_bytes: Some(DEFAULT_THUMBNAIL_BUDGET_BYTES), + keep_opened_originals: true, + } + } +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// What an export starts from before the user changes anything. +/// +/// These are *defaults*, not a preset (FR-EXP-5) — see the module docs. Each +/// field mirrors a requirement rather than an encoder flag, so the export +/// implementation can change without this record changing meaning. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ExportSettings { + pub format: ExportFormat, + + /// Encoder quality, 1..=100. Ignored by lossless formats. + /// + /// Clamped on read rather than trusted: this is hand-editable JSON, and a + /// quality of 0 or 900 reaching an encoder is a panic or a corrupt file. + pub quality: u8, + + pub colour_space: ColourSpace, + pub sizing: SizingMode, + + /// Whether a requested size larger than the source is honoured. + /// + /// Off by default, as FR-EXP-3 requires. Where disabled, an oversized + /// request exports at source size rather than failing — a batch must not + /// abort because one frame was smaller than the target. + pub allow_upscaling: bool, + + pub sharpening: OutputSharpening, + + /// Filename template (FR-EXP-6). Tokens are resolved by the exporter. + pub filename_template: String, + + /// What to do when the output filename already exists. + pub collision: CollisionPolicy, + + /// Whether GPS and other identifying metadata is stripped (FR-EXP-8). + /// + /// Stripping is *on* by default, which is the one place here that departs + /// from "preserve what the camera recorded". An export is usually the copy + /// that leaves the machine, and a home address embedded in a photograph + /// shared publicly is not recoverable once published. The reverse mistake — + /// a user who wanted coordinates and has to re-export — costs a minute. + pub strip_location: bool, + + /// Destination folder. Empty means "ask each time". + /// + /// Empty rather than a guessed `~/Pictures`: a silent default destination + /// is how exports end up somewhere the user never looks, and this is the + /// one field where the app genuinely does not know the answer. + pub destination: String, +} + +impl Default for ExportSettings { + fn default() -> Self { + Self { + format: ExportFormat::Jpeg, + // 90 rather than 100: past roughly this point JPEG spends bytes + // without a visible return, and the default should be the one a + // photographer would not need to change for a web export. + quality: 90, + colour_space: ColourSpace::Srgb, + sizing: SizingMode::Original, + allow_upscaling: false, + sharpening: OutputSharpening::Screen, + filename_template: "{name}".to_string(), + collision: CollisionPolicy::Increment, + strip_location: true, + destination: String::new(), + } + } +} + +/// Output container and codec (FR-EXP-1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExportFormat { + Jpeg, + Png, + /// 8-bit TIFF. + Tiff8, + /// 16-bit TIFF, for work continuing in another editor. + Tiff16, + Avif, + JpegXl, +} + +impl ExportFormat { + /// Every format, in the order the settings page lists them. + /// + /// Ordered by how often a photographer reaches for each rather than + /// alphabetically: JPEG is most exports, the TIFFs are the archival pair, + /// and the modern codecs sit last because support is still uneven. + pub const ALL: [Self; 6] = [ + Self::Jpeg, + Self::Png, + Self::Tiff8, + Self::Tiff16, + Self::Avif, + Self::JpegXl, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Jpeg => "JPEG", + Self::Png => "PNG", + Self::Tiff8 => "TIFF 8-bit", + Self::Tiff16 => "TIFF 16-bit", + Self::Avif => "AVIF", + Self::JpegXl => "JPEG XL", + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::Tiff8 | Self::Tiff16 => "tif", + Self::Avif => "avif", + Self::JpegXl => "jxl", + } + } + + /// Whether the quality setting means anything for this format. + /// + /// Drives whether the page *disables* the quality control rather than + /// hiding it: a control that vanishes when PNG is chosen reads as a bug, + /// where a greyed one explains itself. + pub fn is_lossy(self) -> bool { + matches!(self, Self::Jpeg | Self::Avif | Self::JpegXl) + } +} + +/// Output colour space, with its ICC profile embedded on export (FR-EXP-2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColourSpace { + Srgb, + DisplayP3, + AdobeRgb, + ProPhoto, +} + +impl ColourSpace { + pub const ALL: [Self; 4] = [Self::Srgb, Self::DisplayP3, Self::AdobeRgb, Self::ProPhoto]; + + pub fn label(self) -> &'static str { + match self { + Self::Srgb => "sRGB", + Self::DisplayP3 => "Display P3", + Self::AdobeRgb => "Adobe RGB", + Self::ProPhoto => "ProPhoto", + } + } +} + +/// How output dimensions are decided (FR-EXP-3). +/// +/// Only the modes a *default* can sensibly carry are here. The full table in +/// FR-EXP-3 includes print dimensions at a DPI and a target megapixel count; +/// those need units and a resolution alongside the number, which is an export +/// dialogue's job. A default that opened on "300mm at 240dpi" would be a +/// setting almost no library wants every time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SizingMode { + /// Full source resolution after the crop. + Original, + /// Pixels on the longer dimension; aspect preserved. + LongEdge(u32), + /// Pixels on the shorter dimension; aspect preserved. + ShortEdge(u32), + /// A factor of the source, as a percentage. + Percentage(u32), +} + +impl SizingMode { + /// The mode's name, without its value. + pub fn label(self) -> &'static str { + match self { + Self::Original => "Original", + Self::LongEdge(_) => "Long edge", + Self::ShortEdge(_) => "Short edge", + Self::Percentage(_) => "Percentage", + } + } + + /// The number this mode carries, if it takes one. + pub fn value(self) -> Option { + match self { + Self::Original => None, + Self::LongEdge(n) | Self::ShortEdge(n) | Self::Percentage(n) => Some(n), + } + } + + /// The same mode carrying `value`, or unchanged where it takes none. + /// + /// Lets the page edit the number without re-deciding the variant, which is + /// what a field beside a mode selector needs. + pub fn with_value(self, value: u32) -> Self { + match self { + Self::Original => Self::Original, + Self::LongEdge(_) => Self::LongEdge(value), + Self::ShortEdge(_) => Self::ShortEdge(value), + Self::Percentage(_) => Self::Percentage(value), + } + } + + /// The modes in page order, each with a usable starting value. + /// + /// Switching to a sized mode has to land on *something*, and these are + /// values a user would plausibly keep: 2048px is a common web long edge, + /// and 100% is the identity, so choosing "Percentage" changes nothing + /// until a number is typed. + pub const CHOICES: [Self; 4] = [ + Self::Original, + Self::LongEdge(2048), + Self::ShortEdge(1600), + Self::Percentage(100), + ]; + + /// Whether two modes are the same variant, ignoring their values. + /// + /// What the page's selection test needs: `LongEdge(2048)` and + /// `LongEdge(900)` are one choice showing different numbers, and equality + /// would light neither when the user had typed their own. + pub fn same_mode(self, other: Self) -> bool { + std::mem::discriminant(&self) == std::mem::discriminant(&other) + } +} + +/// Output sharpening, scaled by the resize factor (FR-EXP-4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputSharpening { + None, + Screen, + MattePaper, + GlossyPaper, +} + +impl OutputSharpening { + pub const ALL: [Self; 4] = [Self::None, Self::Screen, Self::MattePaper, Self::GlossyPaper]; + + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Screen => "Screen", + Self::MattePaper => "Matte paper", + Self::GlossyPaper => "Glossy paper", + } + } +} + +/// What happens when the output filename is taken (FR-EXP-6). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CollisionPolicy { + /// Replace the existing file. + Overwrite, + /// Leave the existing file and export nothing for that image. + Skip, + /// Append a counter: `name-1.jpg`, `name-2.jpg`. + Increment, +} + +impl CollisionPolicy { + pub const ALL: [Self; 3] = [Self::Increment, Self::Skip, Self::Overwrite]; + + pub fn label(self) -> &'static str { + match self { + Self::Overwrite => "Overwrite", + Self::Skip => "Skip", + Self::Increment => "Auto-increment", + } + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +impl Settings { + /// Force hand-edited or older values into a usable range. + /// + /// Called after every read, because this file is plain JSON in a config + /// directory and a user is entitled to edit it. Clamping rather than + /// rejecting: a nonsensical quality should not stop the app from starting, + /// and refusing to load would lose every *other* setting in the file over + /// one bad field. + pub fn sanitise(&mut self) { + self.export.quality = self.export.quality.clamp(1, 100); + + // A zero-pixel or zero-percent export produces no image. Nudged to the + // smallest thing that does, rather than back to the default: the user + // clearly wanted "small", and silently restoring 2048 would ignore + // that. + self.export.sizing = match self.export.sizing { + SizingMode::LongEdge(0) => SizingMode::LongEdge(1), + SizingMode::ShortEdge(0) => SizingMode::ShortEdge(1), + SizingMode::Percentage(0) => SizingMode::Percentage(1), + other => other, + }; + + // An empty template names every output the same thing, so every export + // after the first collides. `{name}` is the identity and what the + // default already is. + if self.export.filename_template.trim().is_empty() { + self.export.filename_template = "{name}".to_string(); + } + } +} + +/// How a byte budget is shown and typed. +/// +/// Gigabytes, one decimal place, because that is the unit a disk is discussed +/// in and the settings page has to round-trip whatever it displays: showing +/// "8.4 GB" and storing something that redisplays as "8.3 GB" makes the field +/// look like it is losing the edit. +pub mod budget { + const BYTES_PER_GB: f64 = (1024 * 1024 * 1024) as f64; + + /// Bytes as a gigabyte figure, rounded to one decimal. + pub fn to_gb(bytes: u64) -> f64 { + ((bytes as f64 / BYTES_PER_GB) * 10.0).round() / 10.0 + } + + /// A gigabyte figure as bytes, or `None` where it is not a positive number. + /// + /// Rejects rather than clamps, because this parses what a user typed: a + /// stray keystroke should leave the previous budget in place, where + /// clamping to a minimum would silently shrink a cache to nothing. + pub fn from_gb(text: &str) -> Option { + let gb: f64 = text.trim().trim_end_matches(|c: char| { + c.is_ascii_alphabetic() || c.is_whitespace() + }).parse().ok()?; + if !gb.is_finite() || gb <= 0.0 { + return None; + } + Some((gb * BYTES_PER_GB) as u64) + } + + /// A budget as the page shows it, unlimited included. + pub fn label(bytes: Option) -> String { + match bytes { + None => "Unlimited".to_string(), + Some(b) => format!("{:.1} GB", to_gb(b)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_round_trip_through_json() { + let settings = Settings::default(); + let json = serde_json::to_string(&settings).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + settings + ); + } + + #[test] + fn an_empty_object_is_the_defaults() { + // A first run has no file, and a file written by an older build is + // missing whatever was added since. Neither is an error. + assert_eq!( + serde_json::from_str::("{}").unwrap(), + Settings::default() + ); + } + + #[test] + fn a_partial_file_keeps_the_defaults_for_absent_fields() { + let json = r#"{"export": {"quality": 75}}"#; + let parsed: Settings = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.export.quality, 75); + assert_eq!(parsed.export.format, ExportFormat::Jpeg); + assert_eq!(parsed.cache, CacheSettings::default()); + } + + #[test] + fn upscaling_is_off_by_default() { + // FR-EXP-3 requires an explicit opt-in. + assert!(!ExportSettings::default().allow_upscaling); + } + + #[test] + fn location_metadata_is_stripped_by_default() { + assert!(ExportSettings::default().strip_location); + } + + #[test] + fn sanitise_clamps_an_out_of_range_quality() { + let mut s = Settings::default(); + s.export.quality = 200; + s.sanitise(); + assert_eq!(s.export.quality, 100); + + s.export.quality = 0; + s.sanitise(); + assert_eq!(s.export.quality, 1); + } + + #[test] + fn sanitise_rescues_a_zero_dimension() { + let mut s = Settings::default(); + s.export.sizing = SizingMode::LongEdge(0); + s.sanitise(); + assert_eq!(s.export.sizing, SizingMode::LongEdge(1)); + } + + #[test] + fn sanitise_restores_an_empty_template() { + // Otherwise every export after the first collides on one filename. + let mut s = Settings::default(); + s.export.filename_template = " ".to_string(); + s.sanitise(); + assert_eq!(s.export.filename_template, "{name}"); + } + + #[test] + fn quality_is_ignored_by_lossless_formats() { + assert!(!ExportFormat::Png.is_lossy()); + assert!(!ExportFormat::Tiff16.is_lossy()); + assert!(ExportFormat::Jpeg.is_lossy()); + } + + #[test] + fn a_sizing_mode_keeps_its_variant_when_the_value_changes() { + let mode = SizingMode::LongEdge(2048); + assert_eq!(mode.with_value(900), SizingMode::LongEdge(900)); + assert!(mode.same_mode(SizingMode::LongEdge(900))); + assert!(!mode.same_mode(SizingMode::ShortEdge(2048))); + } + + #[test] + fn original_sizing_takes_no_value() { + assert_eq!(SizingMode::Original.value(), None); + // Setting one on a mode that has none must not invent a variant. + assert_eq!(SizingMode::Original.with_value(500), SizingMode::Original); + } + + #[test] + fn a_budget_round_trips_through_the_field_it_is_shown_in() { + // The page shows one decimal place and parses back what it showed. If + // these disagreed, an untouched field would appear to change on save. + let bytes = 8 * 1024 * 1024 * 1024; + let shown = budget::label(Some(bytes)); + assert_eq!(shown, "8.0 GB"); + assert_eq!(budget::from_gb(&shown), Some(bytes)); + } + + #[test] + fn a_budget_accepts_a_typed_unit_or_none() { + let gb = 1024 * 1024 * 1024; + assert_eq!(budget::from_gb("4"), Some(4 * gb)); + assert_eq!(budget::from_gb("4 GB"), Some(4 * gb)); + assert_eq!(budget::from_gb(" 4gb "), Some(4 * gb)); + } + + #[test] + fn a_meaningless_budget_is_rejected_rather_than_clamped() { + // The caller keeps the previous value: clamping a typo to a minimum + // would silently shrink the cache to nothing. + assert_eq!(budget::from_gb(""), None); + assert_eq!(budget::from_gb("lots"), None); + assert_eq!(budget::from_gb("-2"), None); + assert_eq!(budget::from_gb("0"), None); + } + + #[test] + fn unlimited_is_shown_as_a_word_not_a_number() { + assert_eq!(budget::label(None), "Unlimited"); + } +} diff --git a/docs/traceability.md b/docs/traceability.md index 6c5e4c1..432fc8e 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 82 | -| TRACES tags found | 80 | +| Source files scanned | 92 | +| TRACES tags found | 130 | | Requirements defined | 149 | -| Requirements covered | 60 | -| **Coverage** | **40.3%** (60/149) | +| Requirements covered | 69 | +| **Coverage** | **46.3%** (69/149) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 44 | 95 | +| FR | 53 | 95 | | NFR | 14 | 48 | | R | 2 | 6 | @@ -33,21 +33,21 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | ID | Tagged in | |---|---| -| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:83`](../core/dr-sync/src/scan.rs#L83), [`core/dr-types/src/lib.rs:176`](../core/dr-types/src/lib.rs#L176), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-11 | [`ui/dr-ui/src/library.rs:122`](../ui/dr-ui/src/library.rs#L122) | +| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:181`](../core/dr-types/src/lib.rs#L181), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | +| FR-CAT-11 | [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) | -| FR-CAT-1a | [`core/dr-types/src/lib.rs:38`](../core/dr-types/src/lib.rs#L38) | +| FR-CAT-1a | [`core/dr-types/src/lib.rs:43`](../core/dr-types/src/lib.rs#L43) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:231`](../core/dr-decode/src/lib.rs#L231), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:139`](../ui/dr-ui/src/library.rs#L139) | -| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:228`](../ui/dr-ui/src/library.rs#L228) | -| FR-CAT-9 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:95`](../core/dr-types/src/lib.rs#L95) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:165`](../ui/dr-ui/src/library.rs#L165) | +| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:277`](../ui/dr-ui/src/library.rs#L277) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:100`](../core/dr-types/src/lib.rs#L100), [`ui/dr-ui/src/library.rs:126`](../ui/dr-ui/src/library.rs#L126), [`ui/dr-ui/src/library.rs:182`](../ui/dr-ui/src/library.rs#L182), [`ui/dr-ui/src/library.rs:2118`](../ui/dr-ui/src/library.rs#L2118), [`ui/dr-ui/src/library.rs:987`](../ui/dr-ui/src/library.rs#L987), [`ui/dr-ui/src/library_ui.rs:1243`](../ui/dr-ui/src/library_ui.rs#L1243), [`ui/dr-ui/src/library_ui.rs:1398`](../ui/dr-ui/src/library_ui.rs#L1398), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1683`](../ui/dr-ui/src/library_ui.rs#L1683), [`ui/dr-ui/src/library_ui.rs:1757`](../ui/dr-ui/src/library_ui.rs#L1757), [`ui/dr-ui/src/library_ui.rs:1867`](../ui/dr-ui/src/library_ui.rs#L1867), [`ui/dr-ui/src/library_ui.rs:2712`](../ui/dr-ui/src/library_ui.rs#L2712), [`ui/dr-ui/src/library_ui.rs:2740`](../ui/dr-ui/src/library_ui.rs#L2740), [`ui/dr-ui/src/library_ui.rs:281`](../ui/dr-ui/src/library_ui.rs#L281), [`ui/dr-ui/src/library_ui.rs:938`](../ui/dr-ui/src/library_ui.rs#L938), [`ui/dr-ui/src/library_ui.rs:980`](../ui/dr-ui/src/library_ui.rs#L980) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:139`](../ui/dr-ui/src/library.rs#L139), [`ui/dr-ui/src/library.rs:228`](../ui/dr-ui/src/library.rs#L228) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:165`](../ui/dr-ui/src/library.rs#L165), [`ui/dr-ui/src/library.rs:277`](../ui/dr-ui/src/library.rs#L277) | | FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) | | FR-DEV-3a | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:138`](../core/dr-pipeline/src/graph.rs#L138), [`core/dr-pipeline/src/graph.rs:15`](../core/dr-pipeline/src/graph.rs#L15), [`core/dr-pipeline/src/graph.rs:39`](../core/dr-pipeline/src/graph.rs#L39), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:39`](../core/dr-pipeline/src/graph.rs#L39), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) | @@ -55,26 +55,35 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) | | FR-DEV-3e | [`core/dr-decode/src/lib.rs:442`](../core/dr-decode/src/lib.rs#L442), [`core/dr-decode/src/lib.rs:562`](../core/dr-decode/src/lib.rs#L562) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:123`](../core/dr-gpu/src/lib.rs#L123) | -| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:40`](../ui/dr-ui/src/lib.rs#L40) | +| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:43`](../ui/dr-ui/src/lib.rs#L43) | +| FR-EXP-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-3 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-4 | [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-EXP-6 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-8 | [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-9 | [`core/dr-decode/src/lib.rs:359`](../core/dr-decode/src/lib.rs#L359) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:95`](../core/dr-sync-nextcloud/src/session.rs#L95), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | -| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync/src/lib.rs:153`](../core/dr-sync/src/lib.rs#L153), [`core/dr-sync/src/lib.rs:36`](../core/dr-sync/src/lib.rs#L36) | +| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:95`](../core/dr-sync-nextcloud/src/session.rs#L95) | | FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:153`](../core/dr-sync/src/lib.rs#L153), [`core/dr-sync/src/scan.rs:83`](../core/dr-sync/src/scan.rs#L83), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | +| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) | -| FR-NC-6a | [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1) | -| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:177`](../core/dr-types/src/lib.rs#L177), [`core/dr-types/src/lib.rs:95`](../core/dr-types/src/lib.rs#L95) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:228`](../ui/dr-ui/src/library.rs#L228) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1281`](../ui/dr-ui/src/lib.rs#L1281), [`ui/dr-ui/src/lib.rs:876`](../ui/dr-ui/src/lib.rs#L876), [`ui/dr-ui/src/library.rs:813`](../ui/dr-ui/src/library.rs#L813), [`ui/dr-ui/src/library.rs:826`](../ui/dr-ui/src/library.rs#L826), [`ui/dr-ui/src/library.rs:987`](../ui/dr-ui/src/library.rs#L987), [`ui/dr-ui/src/library_ui.rs:1043`](../ui/dr-ui/src/library_ui.rs#L1043), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:252`](../ui/dr-ui/src/library_ui.rs#L252), [`ui/dr-ui/src/library_ui.rs:2729`](../ui/dr-ui/src/library_ui.rs#L2729), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:338`](../ui/dr-ui/src/library_ui.rs#L338), [`ui/dr-ui/src/library_ui.rs:701`](../ui/dr-ui/src/library_ui.rs#L701), [`ui/dr-ui/src/library_ui.rs:802`](../ui/dr-ui/src/library_ui.rs#L802), [`ui/dr-ui/src/library_ui.rs:905`](../ui/dr-ui/src/library_ui.rs#L905), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:783`](../ui/dr-ui/ui/library.slint#L783) | +| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:100`](../core/dr-types/src/lib.rs#L100), [`core/dr-types/src/lib.rs:182`](../core/dr-types/src/lib.rs#L182) | +| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:277`](../ui/dr-ui/src/library.rs#L277) | | FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212) | -| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:38`](../core/dr-types/src/lib.rs#L38) | +| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:43`](../core/dr-types/src/lib.rs#L43) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| FR-RAW-1 | [`core/dr-decode/src/lib.rs:189`](../core/dr-decode/src/lib.rs#L189), [`core/dr-types/src/lib.rs:105`](../core/dr-types/src/lib.rs#L105), [`core/dr-types/src/lib.rs:176`](../core/dr-types/src/lib.rs#L176) | +| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:189`](../core/dr-decode/src/lib.rs#L189), [`core/dr-types/src/lib.rs:110`](../core/dr-types/src/lib.rs#L110), [`core/dr-types/src/lib.rs:181`](../core/dr-types/src/lib.rs#L181) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:359`](../core/dr-decode/src/lib.rs#L359), [`core/dr-decode/src/lib.rs:85`](../core/dr-decode/src/lib.rs#L85) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:113`](../core/dr-decode/src/lib.rs#L113) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) | -| FR-UI-2 | [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:51`](../ui/dr-ui/src/lib.rs#L51) | +| FR-UI-2 | [`ui/dr-ui/src/lib.rs:51`](../ui/dr-ui/src/lib.rs#L51) | | FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | @@ -88,15 +97,15 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:40`](../ui/dr-ui/src/lib.rs#L40) | -| NFR-RES-4 | [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:265`](../core/dr-thumbs/src/lib.rs#L265) | +| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:43`](../ui/dr-ui/src/lib.rs#L43) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:341`](../core/dr-thumbs/src/lib.rs#L341) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | R1 | [`tools/traceability/src/lib.rs:489`](../tools/traceability/src/lib.rs#L489), [`tools/traceability/src/lib.rs:493`](../tools/traceability/src/lib.rs#L493) | | R4 | [`core/dr-gpu/src/lib.rs:123`](../core/dr-gpu/src/lib.rs#L123) | ## Not yet tagged -89 of 149 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +80 of 149 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -127,24 +136,15 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc - FR-DSP-6 - FR-DSP-7 - FR-DSP-8 -- FR-EXP-1 -- FR-EXP-2 -- FR-EXP-3 -- FR-EXP-4 -- FR-EXP-5 -- FR-EXP-6 - FR-EXP-7 -- FR-EXP-8 - FR-NC-10 - FR-NC-11 - FR-NC-6 - FR-NC-6b -- FR-NC-7 - FR-PLAT-AND-2 - FR-PLAT-AND-4 - FR-PLAT-AND-5 - FR-PLAT-AND-6 -- FR-PLAT-LIN-1 - FR-PLAT-LIN-2 - FR-PLAT-LIN-3 - FR-RAW-2 diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 424480f..4aca3c9 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -31,6 +31,9 @@ rusqlite.workspace = true slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg"] } wgpu.workspace = true anyhow.workspace = true +# `SettingsError` distinguishes an io failure from a malformed file, which the +# settings page reports differently; anyhow would flatten both to a string. +thiserror.workspace = true log.workspace = true pollster.workspace = true # Runtime YAML only for `live-style`; release builds read the tokens the diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index 5aafaea..e90665a 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -268,6 +268,16 @@ fn spawn_login(weak: slint::Weak, ctl: Rc, server: // as "failed unexpectedly", losing the one piece of information that // would explain the failure. Catch it and forward the message instead. let panic_tx = tx.clone(); + // Logging is unreliable here: android_logger delivers lines emitted + // during startup but nothing from this thread, so a failure that never + // sends is otherwise completely opaque. Reporting the last step reached + // through the channel puts it on screen, which is the one channel known + // to work. + let step_tx = tx.clone(); + let step = |s: &str| { + let _ = step_tx.send(LoginMessage::Progress(s.to_string())); + }; + step("thread started"); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { // `enable_all()` also enables the signal driver, which wants to own // process-wide signal handling and is not something a worker thread @@ -280,12 +290,13 @@ fn spawn_login(weak: slint::Weak, ctl: Rc, server: { Ok(rt) => rt, Err(e) => { - let _ = tx.send(LoginMessage::Failed(e.to_string())); + let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}"))); return; } }; + step("runtime built"); - run_login_flow(rt, tx, server); + run_login_flow(rt, tx, server, &step); })); if let Err(panic) = result { @@ -308,16 +319,19 @@ fn run_login_flow( rt: tokio::runtime::Runtime, tx: std::sync::mpsc::Sender, server: String, + step: &dyn Fn(&str), ) { { rt.block_on(async { + step("building http client"); let client = match dr_sync_nextcloud::http_client("DarkRoom") { Ok(c) => c, Err(e) => { - let _ = tx.send(LoginMessage::Failed(e.to_string())); + let _ = tx.send(LoginMessage::Failed(format!("http client: {e}"))); return; } }; + step("requesting login flow"); log::info!("POST {server}/index.php/login/v2"); let flow = match auth::begin(&client, &server, "DarkRoom").await { @@ -361,6 +375,8 @@ fn run_login_flow( } enum LoginMessage { + /// The last step the worker reached, for diagnosis when it dies silently. + Progress(String), AwaitingApproval(String), Success(Box<(dr_sync_nextcloud::AppCredentials, String)>), Failed(String), @@ -374,6 +390,9 @@ fn poll_channel( ) { let timer = slint::Timer::default(); let ctl_for_cb = ctl.clone(); + // Remembers the worker's last reported step, so a silent death names the + // point it got to rather than saying nothing. + let last_step = RefCell::new(String::from("nothing")); timer.start( slint::TimerMode::Repeated, @@ -398,9 +417,10 @@ fn poll_channel( // sending anything, which `catch_unwind` in // spawn_login should now prevent — so say that the // worker stopped rather than blaming the sign-in. - ctl.model - .borrow_mut() - .fail("the sign-in worker stopped without reporting why"); + ctl.model.borrow_mut().fail(format!( + "the sign-in worker stopped after: {}", + last_step.borrow() + )); render(&w, ctl); } if let Some(t) = ctl.poll_timer.borrow().as_ref() { @@ -412,6 +432,9 @@ fn poll_channel( let mut done = false; match msg { + LoginMessage::Progress(s) => { + *last_step.borrow_mut() = s; + } LoginMessage::AwaitingApproval(url) => { ctl.model.borrow_mut().await_approval(url); } diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 50d75e4..5ecda90 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -20,6 +20,8 @@ mod develop; mod labels; mod library; mod library_ui; +mod settings_store; +mod settings_ui; mod trash; #[cfg(live_style)] mod live_style; @@ -540,6 +542,50 @@ pub fn run(paths: Vec) -> Result<()> { } } + // Settings: cache ceilings and export defaults, in their own config file. + // + // Wired independently of every view above. It reads no library and holds no + // session, so it has nothing to be sequenced against — which is the reason + // it is a page reachable from anywhere rather than a panel inside one view. + { + let settings = settings_ui::SettingsController::new(); + // What the cache actually holds, so the ceiling above it is a figure + // the user can judge rather than an abstract one. + settings.set_usage_label(describe_cache_usage(&library)); + // Rendered once up front so the page is correct the first time it is + // opened, rather than on the second open after a callback has run. + settings_ui::render(&window, &settings); + + // Apply what is on disk before anything can use it. Without this the + // controller's defaults stand until the user happens to open the + // settings page and change something — so a cache deliberately capped + // at 2 GB last session would spend this one filling to the default. + { + let stored = settings.snapshot(); + library.set_cache_budget(stored.cache.original_budget_bytes); + library.set_keep_opened_originals(stored.cache.keep_opened_originals); + } + + let lib = library.clone(); + let ctl = settings.clone(); + let weak = window.as_weak(); + settings_ui::wire(&window, settings.clone(), move |s| { + // A ceiling that moved has to be applied to what is already on + // disk, or lowering it would only affect future downloads and the + // cache would sit over budget indefinitely. + lib.set_cache_budget(s.cache.original_budget_bytes); + lib.set_keep_opened_originals(s.cache.keep_opened_originals); + + // Lowering the ceiling evicts, so the figure beside it has just + // changed — leaving the old one would show the cache still over a + // limit that was enforced a moment ago. + ctl.set_usage_label(describe_cache_usage(&lib)); + if let Some(w) = weak.upgrade() { + settings_ui::render(&w, &ctl); + } + }); + } + // The device is shared by demosaic and the adjust pass. Without one the // app still browses through the preview path, just without develop. let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) { @@ -827,10 +873,20 @@ pub fn run(paths: Vec) -> Result<()> { return; }; + // TRACES: FR-NC-6a + // The cache is consulted first, so a second open of the same + // photograph is a disk read rather than a second download of tens + // of megabytes — and so a session's worth of images stays + // openable when the connection goes. + let cache = library.cache_context(&path); + if cache.is_none() { + log::debug!("no originals cache for {path}; fetching every time"); + } + log::info!("fetching {path} for develop"); w.set_load_error("Downloading…".into()); - let rx = library::spawn_full_fetch(creds, user_id, path.clone()); + let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache); // Polled on the UI thread rather than joined: a join would freeze // the window for the length of the download. @@ -1222,6 +1278,50 @@ pub fn run(paths: Vec) -> Result<()> { Ok(()) } +/// TRACES: FR-NC-6a +/// What the originals cache is holding, for the settings page. +/// +/// Pinned and passive are reported separately because they answer different +/// questions: the passive figure is what the budget above it governs, while +/// the pinned figure is disk the user asked for and no ceiling will reclaim. +/// One combined number would make the budget look wrong whenever a large +/// collection was pinned. +fn describe_cache_usage(library: &Rc) -> String { + let Some(cache) = library.cache() else { + return String::new(); + }; + let catalog = library.catalog(); + let borrow = catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return String::new(); + }; + let Ok(usage) = cache.usage(catalog.connection()) else { + return String::new(); + }; + + let gb = |b: u64| b as f64 / 1_073_741_824.0; + match (usage.passive_count, usage.pinned_count) { + (0, 0) => "Nothing cached yet".to_string(), + (_, 0) => format!( + "{:.1} GB cached ({} images)", + gb(usage.passive_bytes), + usage.passive_count + ), + (0, _) => format!( + "{:.1} GB pinned ({} images)", + gb(usage.pinned_bytes), + usage.pinned_count + ), + _ => format!( + "{:.1} GB cached ({} images) · {:.1} GB pinned ({} images)", + gb(usage.passive_bytes), + usage.passive_count, + gb(usage.pinned_bytes), + usage.pinned_count + ), + } +} + fn describe_camera(m: &Metadata) -> String { match (&m.make, &m.model) { (Some(make), Some(model)) => { diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 74159b2..022da8e 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -810,6 +810,200 @@ impl std::fmt::Display for FetchFailure { } } +/// TRACES: FR-NC-6a +/// Progress from the pin worker. +#[derive(Debug)] +pub enum PinMessage { + /// How many originals the pin still needs. Sent once, before any transfer. + Planned { total: usize }, + /// One original landed. + Stored { done: usize }, + /// The pin is fully downloaded. + Done { stored: usize, bytes: u64 }, + Failed { message: String, offline: bool }, +} + +/// TRACES: FR-NC-6a +/// Download every original a pin has asked for. +/// +/// Whole files, deliberately: a pin exists so the photographs can be *edited* +/// away from the server, and develop needs every photosite. This is the one +/// place in the app that fetches originals in bulk, which is why FR-NC-6 +/// makes it opt-in rather than something sync does on its own. +/// +/// Sequential rather than parallel. The lanes that make the thumbnail sweep +/// fast are wrong here: these are tens of megabytes each, so concurrency buys +/// little against a single connection's bandwidth and costs a great deal of +/// memory — and it is the same contention that produced 423 Locked in the +/// sweep. +pub fn spawn_pin_fetch( + creds: AppCredentials, + user_id: String, + catalog_path: PathBuf, + cache_dir: PathBuf, + budget: dr_catalog::Budget, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let (store, catalog) = match ( + dr_catalog::Cache::open(&cache_dir, budget), + Catalog::open(&catalog_path), + ) { + (Ok(s), Ok(c)) => (s, c), + (Err(e), _) | (_, Err(e)) => { + let _ = tx.send(PinMessage::Failed { + message: e.to_string(), + offline: false, + }); + return; + } + }; + + let pending = match store.pending_pins(catalog.connection()) { + Ok(p) => p, + Err(e) => { + let _ = tx.send(PinMessage::Failed { + message: e.to_string(), + offline: false, + }); + return; + } + }; + + if tx + .send(PinMessage::Planned { + total: pending.len(), + }) + .is_err() + { + return; + } + if pending.is_empty() { + let _ = tx.send(PinMessage::Done { + stored: 0, + bytes: 0, + }); + return; + } + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(PinMessage::Failed { + message: e.to_string(), + offline: false, + }); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(PinMessage::Failed { + message: e.to_string(), + offline: false, + }); + return; + } + }; + + let mut stored = 0usize; + let mut bytes_total = 0u64; + + for image in pending { + let Some(source_ref) = source_ref_of(&catalog, image) else { + // Catalogued and then removed while the pin was pending. + continue; + }; + + let id = RemoteId::Path(RemotePath::new(&source_ref)); + match backend.get(&id, None).await { + Ok(bytes) => { + // `pinned: true` — this is the population the budget + // must never evict, which is the entire promise the + // user made when they pinned the collection. + if let Err(e) = store.store( + catalog.connection(), + image, + &source_ref, + &bytes, + true, + now_secs(), + ) { + log::warn!("storing pinned {source_ref}: {e}"); + continue; + } + stored += 1; + bytes_total += bytes.len() as u64; + if tx.send(PinMessage::Stored { done: stored }).is_err() { + return; + } + } + Err(e) if e.indicates_offline() => { + // Stop rather than failing each remaining file against + // a dead connection. What was downloaded stays + // downloaded, and `pending_pins` resumes from there. + let _ = tx.send(PinMessage::Failed { + message: e.to_string(), + offline: true, + }); + return; + } + Err(e) => { + // One unreadable file must not abandon the whole pin. + log::warn!("pinning {source_ref}: {e}"); + } + } + } + + let _ = tx.send(PinMessage::Done { + stored, + bytes: bytes_total, + }); + }); + }); + + rx +} + +/// The remote path for a catalogued image. +fn source_ref_of(catalog: &Catalog, image: dr_types::ImageId) -> Option { + catalog + .connection() + .query_row( + "SELECT source_ref FROM images WHERE id = ?1", + rusqlite::params![image.0 as i64], + |r| r.get(0), + ) + .ok() +} + +/// TRACES: FR-NC-6a | FR-CAT-9 +/// Where a cached original is kept and how much may be kept. +/// +/// Passed in rather than derived here so the caller owns the policy: the +/// budget is a user setting, and this function is on a worker thread with no +/// access to one. +pub struct CacheContext { + pub dir: PathBuf, + pub catalog_path: PathBuf, + pub image: dr_types::ImageId, + pub budget: dr_catalog::Budget, + /// Whether a downloaded original is kept. + /// + /// Only the write. A cache is always *read*, because bytes already on disk + /// cost nothing to use and declining them would re-download an image that + /// is present — including every pinned one, which would leave a pinned + /// collection unopenable offline the moment this was switched off. + pub store: bool, +} + /// Fetch one file in full, for opening it in develop. /// /// Deliberately *not* the preview path. Browsing fetches a range and decodes @@ -817,16 +1011,50 @@ impl std::fmt::Display for FetchFailure { /// needs every photosite. On a RAW file that is tens of megabytes, which is /// why this is a click-triggered download and not something the grid does. /// +/// # Read-through +/// +/// With a `cache`, this checks disk before the network and stores what it +/// downloads. That is what makes opening the same photograph twice cost one +/// transfer, and what leaves a working session's images openable offline +/// without anyone having pinned anything. +/// +/// A cache miss is not an error and a cache failure is not fatal: both fall +/// through to the network, which is exactly the behaviour that existed before +/// the cache did. +/// /// Returns the bytes on a channel rather than blocking: the download runs on /// its own thread and the UI stays live, exactly as thumbnail fetching does. pub fn spawn_full_fetch( creds: AppCredentials, user_id: String, path: String, + cache: Option, ) -> Receiver, FetchFailure>> { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { + // Opened on this thread: `rusqlite::Connection` is not `Send`, and the + // UI thread's handle cannot be borrowed across the spawn. + let cached = cache.as_ref().and_then(|c| { + let store = dr_catalog::Cache::open(&c.dir, c.budget).ok()?; + let conn = Catalog::open(&c.catalog_path).ok()?; + Some((store, conn)) + }); + + if let (Some(c), Some((store, conn))) = (cache.as_ref(), cached.as_ref()) { + match store.load(conn.connection(), c.image, now_secs()) { + Ok(Some(bytes)) => { + log::info!("{path}: {} bytes from the local cache", bytes.len()); + let _ = tx.send(Ok(bytes)); + return; + } + Ok(None) => {} + // A cache that cannot be read is a cache miss, not a failure + // to open the photograph. + Err(e) => log::debug!("cache lookup for {path}: {e}"), + } + } + let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -849,6 +1077,26 @@ pub fn spawn_full_fetch( let id = RemoteId::Path(RemotePath::new(&path)); let got = backend.get(&id, None).await.map_err(FetchFailure::from); + + // Store before sending, so the bytes are on disk by the time the + // image is on screen. Doing it after would leave a window where + // closing the app immediately lost the download. + if let (Ok(bytes), Some(c), Some((store, conn))) = + (&got, cache.as_ref().filter(|c| c.store), cached.as_ref()) + { + // `pinned: false` — this is the passive population. A pin is + // something the user asks for explicitly; opening an image is + // not that, and treating it as one would make the pinned set + // grow silently and never be evicted. + if let Err(e) = + store.store(conn.connection(), c.image, &path, bytes, false, now_secs()) + { + log::debug!("caching {path}: {e}"); + } else if let Err(e) = store.enforce(conn.connection()) { + log::debug!("enforcing the cache budget: {e}"); + } + } + let _ = tx.send(got); }); }); diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 8ba4fda..7aba8d0 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -12,6 +12,7 @@ //! timers, which is the same shape [`crate::launch_ui`] uses for login. use std::cell::RefCell; +use std::path::PathBuf; use std::rc::Rc; use std::sync::mpsc::Receiver; @@ -83,7 +84,19 @@ pub struct LibraryController { window: RefCell, /// Which rows already have a thumbnail fetch issued, so scrolling back /// does not refetch what is already on screen. - requested: RefCell>, + /// Which images have a fetch in flight or already served, keyed on + /// `(image_id, size class)`. + /// + /// **Identity, not row index.** The grid is a window over the catalog, so + /// row 7 means a different photograph after every scroll — a set of + /// indices had to be cleared on each window move, which made every visible + /// cell look unrequested and re-issued the whole screenful on every + /// scroll. + /// + /// The size class is part of the key because the two resolutions are + /// fetched independently: holding the 256px one says nothing about whether + /// the large one has been asked for. + requested: RefCell>, scan_timer: RefCell>, thumb_timer: RefCell>, /// The whole-library sweep, which outlives any one grid window. @@ -157,6 +170,10 @@ pub struct LibraryController { /// judgement, and carrying the old one over would report a server down /// that was never contacted. reachability: RefCell, + /// TRACES: FR-NC-6a + /// Drains the pin downloader. Held so a second pin replaces the timer + /// rather than leaving two draining the same finished channel. + pin_timer: RefCell>, /// Narrow the grid to images whose original is stored locally. /// /// A `Cell` beside `filter` rather than a field inside it: the rating @@ -164,6 +181,23 @@ pub struct LibraryController { /// predicate over the cache, and folding two different joins into one type /// would put the cache schema inside a rating concept. local_only: std::cell::Cell, + /// TRACES: FR-NC-6a + /// The ceiling on passively cached originals, from the settings page. + /// + /// Held here rather than read from `SettingsStore` at each use because the + /// workers that need it run on threads with no config access — the same + /// reason [`library::CacheContext`] takes it as a field. A `Cell` because + /// the settings page can move it while a library is open, and the next + /// fetch must use the new figure rather than one captured at open. + cache_budget: std::cell::Cell, + /// TRACES: FR-NC-6a + /// Whether opening an image in develop keeps its original on disk. + /// + /// Held beside the budget and for the same reason. Distinct from a zero + /// budget: this switches the passive population off entirely, while a + /// small budget still keeps a working set. A metered or small-disk device + /// wants the first. + keep_opened: std::cell::Cell, } impl LibraryController { @@ -194,10 +228,56 @@ impl LibraryController { sidecar_timer: RefCell::new(None), generation: std::cell::Cell::new(0), reachability: RefCell::new(dr_sync::Reachability::new()), + pin_timer: RefCell::new(None), local_only: std::cell::Cell::new(false), + // The catalog's own floor until the settings page reports what the + // user has stored, which it does at startup before any fetch. + cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()), + keep_opened: std::cell::Cell::new( + dr_types::CacheSettings::default().keep_opened_originals, + ), }) } + /// TRACES: FR-NC-6a + /// Whether a develop open should keep the original it downloads. + /// + /// Turning it off leaves what is already cached alone: those bytes are + /// paid for, and deleting them would make the switch destructive when it + /// only means "stop adding to this". + pub fn set_keep_opened_originals(&self, keep: bool) { + self.keep_opened.set(keep); + } + + /// TRACES: FR-NC-6a + /// Set the ceiling on passively cached originals, and apply it now. + /// + /// Applied immediately rather than only to later downloads: a user who has + /// just lowered the limit expects the space back, and a budget that took + /// effect only on the next fetch would leave the cache over its stated + /// ceiling for as long as they browsed nothing new. + pub fn set_cache_budget(&self, bytes: Option) { + let budget = match bytes { + Some(n) => dr_catalog::Budget::bytes(n), + None => dr_catalog::Budget::unlimited(), + }; + self.cache_budget.set(budget); + + // Best-effort: no library open means no cache to trim, and a failure + // here must not stop the setting from being stored — the ceiling still + // applies to every fetch from now on. + let Some(dir) = self.cache_dir() else { return }; + let borrow = self.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { return }; + match dr_catalog::Cache::open(&dir, budget) + .and_then(|cache| cache.enforce(catalog.connection())) + { + Ok(0) => {} + Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"), + Err(e) => log::warn!("applying the new cache budget: {e}"), + } + } + /// TRACES: FR-CAT-9 /// Whether the app currently believes the server is unreachable. pub fn is_offline(&self) -> bool { @@ -209,6 +289,83 @@ impl LibraryController { self.local_only.get() } + /// TRACES: FR-NC-6a + /// The catalog id for a remote path currently in the grid. + /// + /// The cache is keyed on `ImageId` because that is what survives a + /// server-side rename (FR-NC-5), while the develop callback carries only a + /// path. `paths` and `image_ids` are parallel to the model, so this is the + /// join between the two. + pub fn image_id_for_path(&self, path: &str) -> Option { + let index = self.paths.borrow().iter().position(|p| p == path)?; + self.image_ids + .borrow() + .get(index) + .map(|id| dr_types::ImageId(*id as u64)) + } + + /// TRACES: FR-NC-6a + /// Where cached originals live for the open library. + /// + /// Beside the catalog rather than under a system cache directory: the two + /// are per-account and are discarded together, and a cached original whose + /// catalog row is gone is unreachable anyway. + pub fn cache_dir(&self) -> Option { + let borrow = self.session.borrow(); + let (_, session, _) = borrow.as_ref()?; + library::catalog_path(&session.server, &session.user_id) + .parent() + .map(|p| p.join("originals")) + } + + /// Open the originals cache for the current library. + /// + /// Used by pinning, which writes intent against the catalog directly + /// rather than going through a fetch. + pub fn cache(&self) -> Option { + let dir = self.cache_dir()?; + match dr_catalog::Cache::open(&dir, self.cache_budget.get()) { + Ok(c) => Some(c), + Err(e) => { + // Not fatal: without a cache every open is a download, which + // is exactly the behaviour that existed before this. + log::warn!("originals cache unavailable: {e}"); + None + } + } + } + + /// TRACES: FR-NC-6a + /// Everything a full fetch needs to read and write the cache. + /// + /// Assembled here because the develop callback holds only a path, while + /// the cache is keyed on `ImageId` and lives beside a catalog whose + /// location comes from the session. `None` where any part is missing — a + /// grid row that has scrolled away, or no library open — and the fetch + /// then simply goes to the network. + pub fn cache_context(&self, path: &str) -> Option { + let borrow = self.session.borrow(); + let (_, session, _) = borrow.as_ref()?; + let catalog_path = library::catalog_path(&session.server, &session.user_id); + let dir = catalog_path.parent()?.join("originals"); + drop(borrow); + + Some(library::CacheContext { + dir, + catalog_path, + image: self.image_id_for_path(path)?, + // The user's ceiling, not the catalog's floor: read at each fetch + // so a budget changed mid-session takes effect on the next one. + budget: self.cache_budget.get(), + // Gates the *write* only. Reading stays enabled either way: bytes + // already on disk were paid for, and refusing to use them because + // the user has since stopped adding new ones would re-download + // images that are sitting right there — and would make a pinned + // collection unopenable offline. + store: self.keep_opened.get(), + }) + } + /// Toggle the local-only filter, resetting the window. /// /// The offset is cleared for the same reason a scope change clears it: the @@ -541,6 +698,243 @@ fn start_rescan( drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); } +/// TRACES: FR-NC-6a +/// Pin the scoped collection for offline use, or release it. +/// +/// Pinning is two separate things, and keeping them separate is what makes the +/// button feel immediate: recording the *intent* is a local catalog write that +/// completes at once, and downloading the bytes is a background transfer that +/// may take a very long time. The button reflects the first. +fn toggle_pin_scope(window: &AppWindow, ctl: &Rc) { + let Some(scope) = *ctl.scope.borrow() else { + return; + }; + let Some(cache) = ctl.cache() else { + window.set_library_error("No cache directory for this library.".into()); + return; + }; + + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + // Descendants, matching what the grid shows when scoped to a set: pinning + // a parent whose children hold the photographs must pin the photographs, + // or the button would appear to do nothing. + let ids = match dr_catalog::collections::descendants(catalog.connection(), scope) { + Ok(ids) => ids, + Err(e) => { + window.set_library_error(format!("resolving collection: {e}").into()); + return; + } + }; + let images = collection_images(catalog, &ids); + if images.is_empty() { + window.set_library_error("Nothing in that collection to keep offline.".into()); + return; + } + + let pinning = !window.get_library_scope_pinned(); + let result = if pinning { + cache.pin(catalog.connection(), &images) + } else { + cache.unpin(catalog.connection(), &images) + }; + + if let Err(e) = result { + window.set_library_error(format!("pinning: {e}").into()); + return; + } + + window.set_library_scope_pinned(pinning); + window.set_library_error(slint::SharedString::new()); + drop(borrow); + + if pinning { + log::info!("pinned {} image(s) for offline use", images.len()); + start_pin_fetch(window, ctl); + } else { + // The bytes stay until the budget needs the room, so there is nothing + // to run here — unpinning withdraws a guarantee rather than deleting. + log::info!("released the pin on {} image(s)", images.len()); + window.set_library_pin_total(0); + window.set_library_pin_done(0); + refresh_local_count(window, ctl); + } +} + +/// Every image in the given collections, deduplicated. +/// +/// An image in both a parent and a child is one photograph and must be pinned +/// once, exactly as the grid draws it once. +fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT DISTINCT image_id FROM collection_members + WHERE collection_id IN ({placeholders})" + ); + let params: Vec = ids + .iter() + .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) + .collect(); + + let Ok(mut stmt) = catalog.connection().prepare(&sql) else { + return Vec::new(); + }; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)) + }); + match rows { + Ok(rows) => rows.flatten().collect(), + Err(e) => { + log::debug!("listing collection images: {e}"); + Vec::new() + } + } +} + +/// TRACES: FR-NC-6a +/// Download whatever the pins still want, reporting progress. +fn start_pin_fetch(window: &AppWindow, ctl: &Rc) { + let Some((creds, session, _)) = ctl.session.borrow().clone() else { + return; + }; + let Some(cache_dir) = ctl.cache_dir() else { + return; + }; + + // Offline, there is nothing to download from. The pin is already recorded, + // so it resumes on reconnect rather than being lost. + if ctl.is_offline() { + log::info!("offline: the pin is recorded and will download on reconnect"); + return; + } + + let rx = library::spawn_pin_fetch( + creds, + session.user_id.clone(), + library::catalog_path(&session.server, &session.user_id), + cache_dir, + // Pinned originals are exempt from the budget, but a pin fetch also + // stores passively when it finds an image already cached, so the worker + // still needs the user's ceiling rather than the catalog's floor. + ctl.cache_budget.get(), + ); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(300), + move || { + let Some(w) = weak.upgrade() else { return }; + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_pin_total(0); + stop(&ctl_cb.pin_timer); + return; + } + }; + + match msg { + library::PinMessage::Planned { total } => { + w.set_library_pin_total(total as i32); + w.set_library_pin_done(0); + } + library::PinMessage::Stored { done } => { + w.set_library_pin_done(done as i32); + // The "On this device" count grows as they land, so + // the chip agrees with the progress line beside it. + refresh_local_count(&w, &ctl_cb); + } + library::PinMessage::Done { stored, bytes } => { + log::info!( + "pin complete: {stored} original(s), {:.1} MB", + bytes as f64 / 1_048_576.0 + ); + w.set_library_pin_total(0); + w.set_library_pin_done(0); + refresh_local_count(&w, &ctl_cb); + stop(&ctl_cb.pin_timer); + return; + } + library::PinMessage::Failed { message, offline } => { + log::warn!("pin fetch stopped: {message}"); + w.set_library_pin_total(0); + if offline { + ctl_cb + .reachability + .borrow_mut() + .mark_unreachable(message, std::time::Instant::now()); + refresh_offline(&w, &ctl_cb); + } else { + w.set_library_error(format!("keeping offline: {message}").into()); + } + refresh_local_count(&w, &ctl_cb); + stop(&ctl_cb.pin_timer); + return; + } + } + } + }, + ); + + *ctl.pin_timer.borrow_mut() = Some(timer); +} + +/// Refresh the "On this device" count from the catalog. +fn refresh_local_count(window: &AppWindow, ctl: &Rc) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); +} + +/// TRACES: FR-NC-6a +/// Whether every image in the scoped collection is pinned. +/// +/// Read from the catalog rather than remembered, because a pin outlives the +/// session that made it: reopening the library must show the button already +/// active, or the user would pin the same collection twice. +fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool { + if images.is_empty() { + return false; + } + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let pinned: i64 = catalog + .connection() + .query_row( + &format!( + "SELECT count(*) FROM image_cache + WHERE pinned = 1 AND image_id IN ({placeholders})" + ), + rusqlite::params_from_iter(params.iter()), + |r| r.get(0), + ) + .unwrap_or(0); + + pinned as usize == images.len() +} + /// TRACES: FR-CAT-9 /// Paint the connectivity state into the window. /// @@ -646,6 +1040,19 @@ fn load_window(window: &AppWindow, ctl: &Rc) { // query rather than another predicate threaded through the scoped one. let trash = ctl.viewing_trash.get(); + // TRACES: FR-NC-6a + // Whether the newly scoped collection is already pinned. Read here rather + // than remembered, because a pin outlives the session that made it — on + // reopening the library the button has to show what the catalog says, not + // what this run happens to have done. + window.set_library_scope_pinned(match scope { + Some(id) => dr_catalog::collections::descendants(catalog.connection(), id) + .map(|ids| collection_images(catalog, &ids)) + .map(|images| scope_is_pinned(catalog, &images)) + .unwrap_or(false), + None => false, + }); + let total = if trash { library::total_trashed(catalog).unwrap_or(0) } else { @@ -1097,18 +1504,26 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { paths .iter() .enumerate() - .filter(|(i, _)| requested.insert(*i)) - .map(|(i, p)| library::ThumbnailRequest { + .filter_map(|(i, p)| { + let image_id = *image_ids.get(i)?; // Chosen from how large the cell is actually drawn, so a // zoomed grid asks for detail a 256px thumbnail cannot give // and a wall of small cells does not pay for it. - thumb_size: dr_thumbs::ThumbSize::for_cell(cell_pixels), - row: i, - path: p.clone(), - file_id: file_ids.get(i).copied().flatten(), - size: sizes.get(i).copied().unwrap_or(0), - image_id: image_ids.get(i).copied().unwrap_or(0), - needs_metadata: needs_md.get(i).copied().unwrap_or(false), + let thumb_size = dr_thumbs::ThumbSize::for_cell(cell_pixels); + // Keyed on the photograph, so scrolling back over a cell that + // has already been served does not ask for it again. + if !requested.insert((image_id, thumb_size)) { + return None; + } + Some(library::ThumbnailRequest { + row: i, + path: p.clone(), + file_id: file_ids.get(i).copied().flatten(), + size: sizes.get(i).copied().unwrap_or(0), + image_id, + needs_metadata: needs_md.get(i).copied().unwrap_or(false), + thumb_size, + }) }) .collect() }; @@ -1777,8 +2192,6 @@ fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { // the viewport sits at row 0 shows an empty grid until the user scrolls. window.set_library_scroll_to(position as i32); window.set_library_scroll_token(window.get_library_scroll_token() + 1); - // A new window means new rows; nothing already fetched applies to them. - ctl.requested.borrow_mut().clear(); load_window(window, ctl); } @@ -1893,14 +2306,10 @@ where } w.set_library_cell_size(next); - // Crossing the class boundary means the visible cells now want a - // resolution the store may not hold, and the window's capacity - // changed with the cell size. Both are answered by reloading. - let was = dr_thumbs::ThumbSize::for_cell(current as u32); - let now = dr_thumbs::ThumbSize::for_cell(next as u32); - if was != now { - ctl.requested.borrow_mut().clear(); - } + // No need to forget anything on a class change: the class is part + // of the request key, so cells that now want the large resolution + // simply miss and ask for it, while the 256px ones they already + // hold stay served. load_window(&w, &ctl); }); } @@ -1942,9 +2351,6 @@ where return; } *ctl.window.borrow_mut() = capacity; - // The window's extent changed, so rows outside the old one were - // never requested and rows inside it still hold their thumbnails. - ctl.requested.borrow_mut().clear(); load_window(&w, &ctl); }); } @@ -2028,9 +2434,6 @@ where } *ctl.offset.borrow_mut() = desired; - // A different window means different rows; nothing already - // requested applies to them. - ctl.requested.borrow_mut().clear(); load_window(&w, &ctl); }); } @@ -2173,7 +2576,6 @@ where // from the restored position has loaded rows above it. let window_size = *ctl.window.borrow(); *ctl.offset.borrow_mut() = resume.saturating_sub(window_size / 4); - ctl.requested.borrow_mut().clear(); load_window(&w, &ctl); // Before the grid is shown, not after: the markup gates it on @@ -2324,6 +2726,17 @@ where }); } + // TRACES: FR-NC-6a + // Pin or unpin the collection the grid is scoped to. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_toggle_pin_scope(move || { + let Some(w) = weak.upgrade() else { return }; + toggle_pin_scope(&w, &ctl); + }); + } + // TRACES: FR-CAT-9 // Retry now, rather than waiting out the backoff. A user who has just // reconnected their wifi knows something the backoff does not. @@ -2527,6 +2940,51 @@ mod tests { assert_eq!(ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), ThumbSize::Grid); } + + #[test] + fn a_request_key_survives_the_window_moving() { + use dr_thumbs::ThumbSize; + use std::collections::HashSet; + + // Keyed on the photograph, not its position. The grid is a window over + // the catalog, so row 7 is a different image after every scroll — a + // set of row indices had to be cleared on each move, and every visible + // cell then looked unrequested and was re-issued. + let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new(); + + // A screenful at rows 0..3, holding images 100..103. + for id in 100..103 { + assert!(requested.insert((id, ThumbSize::Grid)), "first sight"); + } + + // Scrolled: the same photographs now occupy different rows. + for id in 100..103 { + assert!( + !requested.insert((id, ThumbSize::Grid)), + "image {id} must not be requested twice" + ); + } + + // A genuinely new photograph still is. + assert!(requested.insert((200, ThumbSize::Grid))); + } + + #[test] + fn the_two_size_classes_are_requested_independently() { + use dr_thumbs::ThumbSize; + use std::collections::HashSet; + + // Holding the 256px version says nothing about the large one, so + // zooming past the boundary must still ask. + let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new(); + assert!(requested.insert((1, ThumbSize::Grid))); + assert!( + requested.insert((1, ThumbSize::Large)), + "the large class is a separate request" + ); + assert!(!requested.insert((1, ThumbSize::Grid))); + } + #[test] fn zoom_zero_is_the_whole_library() { let full = (1_000, 2_000); diff --git a/ui/dr-ui/src/settings_store.rs b/ui/dr-ui/src/settings_store.rs new file mode 100644 index 0000000..755772d --- /dev/null +++ b/ui/dr-ui/src/settings_store.rs @@ -0,0 +1,233 @@ +//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 +//! Reads and writes `settings.json` beside the session config. +//! +//! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore) +//! rather than an extension of it. Two files, two lifetimes: signing out +//! forgets a session and must not discard a cache budget, and resetting +//! preferences must not revoke a credential. Merging them would couple those. +//! +//! There is no `version` field here, unlike `sessions.json`. Every field in +//! [`Settings`] is `#[serde(default)]`, so an older file is missing fields +//! rather than wrong about them, and a newer file read by an older build +//! ignores what it does not know. That covers additive change, which is the +//! only kind this record has had; a field whose *meaning* changes will need a +//! version, and that is the point to add one. + +use std::path::{Path, PathBuf}; + +use dr_types::Settings; + +/// Loads and saves device preferences. +pub struct SettingsStore { + path: PathBuf, +} + +impl SettingsStore { + /// Open the store at the platform config location. + /// + /// Linux: `$XDG_CONFIG_HOME/darkroom/settings.json`, falling back to + /// `~/.config` (FR-PLAT-LIN-1) — the same resolution `SessionStore` does, + /// so the two files sit together and a user backing up one takes both. + pub fn open() -> Self { + let dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config") + }) + .join("darkroom"); + Self::open_at(dir.join("settings.json")) + } + + /// Open at an explicit path — for tests, and for a non-default location. + pub fn open_at(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// The stored settings, or the defaults. + /// + /// A missing file is a first run, not a failure. An *unparseable* file is + /// also answered with defaults rather than an error, because the + /// alternative is an app that will not start until the user hand-edits + /// JSON — and the file is rewritten whole on the next save, so the damage + /// does not persist. The parse failure is logged so it is not silent. + pub fn load(&self) -> Settings { + let mut settings = match std::fs::read_to_string(&self.path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(s) => s, + Err(e) => { + log::warn!( + "{} is not readable settings ({e}); using defaults", + self.path.display() + ); + Settings::default() + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Settings::default(), + Err(e) => { + log::warn!("reading {}: {e}; using defaults", self.path.display()); + Settings::default() + } + }; + + // The file is hand-editable, so nothing downstream may assume a sane + // range until this has run. + settings.sanitise(); + settings + } + + /// Persist settings, replacing whatever was there. + /// + /// A whole-file write rather than a merge: this record is the complete set + /// of preferences and the caller is holding the copy the user just edited. + /// Merging would let a field the page does not yet expose resurrect an old + /// value the user thought they had changed. + pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(settings)?; + + // Write and rename, so an interrupted save cannot truncate the + // existing file — the same discipline `SessionStore` uses. Settings are + // saved on every field edit, which makes the interrupted-write window + // something the user actually meets rather than a theoretical one. + let tmp = self.path.with_extension("tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SettingsError { + #[error("settings io: {0}")] + Io(#[from] std::io::Error), + + #[error("settings format: {0}")] + Serde(#[from] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_types::{settings::budget, ExportFormat}; + + fn tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "dr-settings-test-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn store(name: &str) -> (SettingsStore, PathBuf) { + let dir = tempdir(name); + (SettingsStore::open_at(dir.join("settings.json")), dir) + } + + #[test] + fn a_first_run_gets_the_defaults() { + let (store, _dir) = store("first-run"); + assert!(!store.path().exists()); + assert_eq!(store.load(), Settings::default()); + } + + #[test] + fn settings_survive_a_round_trip() { + let (store, _dir) = store("round-trip"); + + let mut settings = Settings::default(); + settings.export.format = ExportFormat::Tiff16; + settings.export.quality = 72; + settings.cache.original_budget_bytes = budget::from_gb("16"); + settings.cache.keep_opened_originals = false; + + store.save(&settings).unwrap(); + assert_eq!(store.load(), settings); + } + + #[test] + fn an_unlimited_budget_survives_the_file() { + // `None` and `Some(0)` are different settings; a serialiser that + // flattened one into the other would turn "keep everything" into + // "keep nothing" — the worst possible confusion of the two. + let (store, _dir) = store("unlimited"); + + let mut settings = Settings::default(); + settings.cache.original_budget_bytes = None; + store.save(&settings).unwrap(); + + assert_eq!(store.load().cache.original_budget_bytes, None); + } + + #[test] + fn saving_creates_the_config_directory() { + // A first save on a fresh machine has no `~/.config/darkroom` yet. + let dir = tempdir("mkdir"); + let store = SettingsStore::open_at(dir.join("nested").join("settings.json")); + store.save(&Settings::default()).unwrap(); + assert!(store.path().exists()); + } + + #[test] + fn a_corrupt_file_yields_defaults_rather_than_failing() { + // An app that will not start until the user hand-edits JSON is worse + // than one that forgets a preference. + let (store, _dir) = store("corrupt"); + std::fs::write(store.path(), "{ this is not json").unwrap(); + assert_eq!(store.load(), Settings::default()); + } + + #[test] + fn a_file_from_an_older_build_keeps_what_it_does_say() { + // Additive change is the case `serde(default)` covers, and the point of + // having no version field. The named value must survive. + let (store, _dir) = store("older"); + std::fs::write(store.path(), r#"{"export":{"quality":55}}"#).unwrap(); + + let loaded = store.load(); + assert_eq!(loaded.export.quality, 55); + assert_eq!(loaded.cache, dr_types::CacheSettings::default()); + } + + #[test] + fn load_sanitises_a_hand_edited_file() { + let (store, _dir) = store("sanitise"); + std::fs::write(store.path(), r#"{"export":{"quality":250}}"#).unwrap(); + assert_eq!(store.load().export.quality, 100); + } + + #[test] + fn a_save_replaces_rather_than_merging() { + let (store, _dir) = store("replace"); + + let mut first = Settings::default(); + first.export.quality = 50; + store.save(&first).unwrap(); + + // The defaults again: quality must go back to 90, not stay at 50. + store.save(&Settings::default()).unwrap(); + assert_eq!(store.load().export.quality, 90); + } + + #[test] + fn no_temporary_file_is_left_behind() { + let (store, dir) = store("no-temp"); + store.save(&Settings::default()).unwrap(); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "left {leftovers:?} behind"); + } +} diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs new file mode 100644 index 0000000..d91716b --- /dev/null +++ b/ui/dr-ui/src/settings_ui.rs @@ -0,0 +1,562 @@ +//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-8 | FR-NC-6a +//! Wires [`Settings`] to the Slint settings page. +//! +//! Shaped after [`launch_ui`](crate::launch_ui): the record lives in +//! `dr-types` and is tested headless, [`render`] pushes it into window +//! properties, and [`wire`] connects the callbacks. This module moves values +//! across the boundary and decides nothing about what a setting *means*. +//! +//! # Every edit saves +//! +//! There is no Save button. A settings page with one has to answer what +//! happens when the window closes with the button untouched, and every +//! available answer is bad: discarding silently loses work, prompting turns a +//! preference change into a dialogue, and saving anyway makes the button a +//! decoration. Writing on each edit removes the question — the file is a few +//! hundred bytes, written by rename, and the user's last action is always what +//! is stored. +//! +//! A failed write is surfaced rather than swallowed, because the whole +//! contract of this page is that what it shows is what is saved. If the disk +//! is full or the config directory is unwritable, a page that kept displaying +//! the new value would be lying. + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_types::settings::budget; +use dr_types::{ + CollisionPolicy, ColourSpace, ExportFormat, OutputSharpening, Settings, SizingMode, +}; +use slint::ComponentHandle; + +use crate::settings_store::SettingsStore; +use crate::AppWindow; + +/// Shared settings state for the running window. +pub struct SettingsController { + pub settings: RefCell, + pub store: SettingsStore, + /// Reported by the page when a write fails. Held here rather than pushed + /// straight to the window so [`render`] stays the single writer of window + /// properties. + error: RefCell>, + /// How much disk the cache is currently using, as a label. Supplied by + /// whoever owns the catalog — this module has no connection to query. + usage_label: RefCell, +} + +impl SettingsController { + pub fn new() -> Rc { + let store = SettingsStore::open(); + let settings = store.load(); + Rc::new(Self { + settings: RefCell::new(settings), + store, + error: RefCell::new(None), + usage_label: RefCell::new(String::new()), + }) + } + + /// The current settings, for whoever needs to act on them. + pub fn snapshot(&self) -> Settings { + self.settings.borrow().clone() + } + + /// Show what the cache is holding. Empty hides the line. + pub fn set_usage_label(&self, label: String) { + *self.usage_label.borrow_mut() = label; + } + + /// Apply an edit and persist it. + /// + /// Takes a closure rather than a whole `Settings` so a caller cannot + /// accidentally write back a stale copy of the fields it was not editing — + /// with a save on every keystroke, two controls holding their own snapshots + /// would overwrite each other. + fn edit(&self, f: impl FnOnce(&mut Settings)) { + { + let mut settings = self.settings.borrow_mut(); + f(&mut settings); + // The page can produce out-of-range values — a typed quality, a + // pasted number — so the same clamp the file gets on read applies + // here, before anything is stored or shown. + settings.sanitise(); + } + + let snapshot = self.settings.borrow().clone(); + match self.store.save(&snapshot) { + Ok(()) => *self.error.borrow_mut() = None, + Err(e) => { + log::warn!("saving settings: {e}"); + *self.error.borrow_mut() = Some(format!( + "Could not save to {}: {e}", + self.store.path().display() + )); + } + } + } +} + +/// Push the settings into the window's properties. +pub fn render(window: &AppWindow, controller: &SettingsController) { + let s = controller.settings.borrow(); + + // --- cache --------------------------------------------------------- + window.set_settings_original_budget(budget::label(s.cache.original_budget_bytes).into()); + window.set_settings_original_unlimited(s.cache.original_budget_bytes.is_none()); + window.set_settings_thumbnail_budget(budget::label(s.cache.thumbnail_budget_bytes).into()); + window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none()); + window.set_settings_keep_opened(s.cache.keep_opened_originals); + window.set_settings_cache_usage(controller.usage_label.borrow().clone().into()); + + // --- export -------------------------------------------------------- + // + // Choice rows are sent as labels plus the selected index rather than as a + // model of structs: the page draws a row of chips from them and nothing + // else, so a label and an index is the whole of what it needs. + window.set_settings_format_labels(labels(ExportFormat::ALL.iter().map(|f| f.label()))); + window.set_settings_format_selected(index_of(&ExportFormat::ALL, &s.export.format)); + + window.set_settings_quality(s.export.quality as i32); + // Disabled rather than hidden for a lossless format: a control that + // vanishes when PNG is picked reads as a bug, where a greyed one explains + // itself. + window.set_settings_quality_enabled(s.export.format.is_lossy()); + + window.set_settings_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label()))); + window.set_settings_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space)); + + window.set_settings_sizing_labels(labels(SizingMode::CHOICES.iter().map(|m| m.label()))); + // Compared by variant, not by equality: `LongEdge(900)` after the user + // typed their own number is still the "Long edge" choice, and equality + // against `CHOICES` would light nothing. + window.set_settings_sizing_selected( + SizingMode::CHOICES + .iter() + .position(|m| m.same_mode(s.export.sizing)) + .unwrap_or(0) as i32, + ); + window.set_settings_sizing_value(s.export.sizing.value().unwrap_or(0) as i32); + // `Original` carries no number, so the field beside the chips has nothing + // to edit and is hidden rather than shown holding a meaningless zero. + window.set_settings_sizing_has_value(s.export.sizing.value().is_some()); + window.set_settings_sizing_unit( + match s.export.sizing { + SizingMode::Percentage(_) => "%", + _ => "px", + } + .into(), + ); + window.set_settings_allow_upscaling(s.export.allow_upscaling); + + window.set_settings_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label()))); + window.set_settings_sharpening_selected(index_of( + &OutputSharpening::ALL, + &s.export.sharpening, + )); + + window.set_settings_filename_template(s.export.filename_template.clone().into()); + window.set_settings_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label()))); + window.set_settings_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision)); + window.set_settings_strip_location(s.export.strip_location); + + window.set_settings_destination(s.export.destination.clone().into()); + + window.set_settings_error( + controller + .error + .borrow() + .clone() + .unwrap_or_default() + .into(), + ); +} + +/// A label list as a Slint model. +fn labels<'a>(items: impl Iterator) -> slint::ModelRc { + let v: Vec = items.map(slint::SharedString::from).collect(); + slint::ModelRc::new(slint::VecModel::from(v)) +} + +/// Where `value` sits in `all`, as the index the page selects by. +fn index_of(all: &[T], value: &T) -> i32 { + all.iter().position(|v| v == value).unwrap_or(0) as i32 +} + +/// Connect the page's callbacks. +/// +/// `on_budget_changed` runs when a cache ceiling moves, so the caller can +/// enforce it against the catalog — this module has no connection and must not +/// grow one. +pub fn wire(window: &AppWindow, controller: Rc, on_budget_changed: F) +where + F: Fn(&Settings) + 'static, +{ + let on_budget_changed = Rc::new(on_budget_changed); + + // --- opening and closing ------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_open(move || { + let Some(w) = weak.upgrade() else { return }; + // Re-read from disk on open rather than trusting the copy in + // memory: another instance of the app may have written the file + // since, and showing a stale value would let this window save it + // back over the newer one. + *ctl.settings.borrow_mut() = ctl.store.load(); + render(&w, &ctl); + w.set_show_settings(true); + }); + } + + { + let weak = window.as_weak(); + window.on_settings_close(move || { + let Some(w) = weak.upgrade() else { return }; + w.set_show_settings(false); + }); + } + + // --- cache --------------------------------------------------------- + // + // A budget arrives as typed text. An unparseable entry leaves the previous + // value in place and `render` puts the stored one back in the field, so a + // typo is visibly rejected rather than silently shrinking a cache. + { + let weak = window.as_weak(); + let ctl = controller.clone(); + let notify = on_budget_changed.clone(); + window.on_settings_original_budget_changed(move |text| { + let Some(w) = weak.upgrade() else { return }; + if let Some(bytes) = budget::from_gb(&text) { + ctl.edit(|s| s.cache.original_budget_bytes = Some(bytes)); + notify(&ctl.snapshot()); + } else { + log::debug!("ignoring an unusable cache budget: {text:?}"); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + let notify = on_budget_changed.clone(); + window.on_settings_original_unlimited_toggled(move |unlimited| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| { + s.cache.original_budget_bytes = if unlimited { + None + } else { + // Back to the default rather than to whatever it was + // before: the previous figure is not kept while unlimited + // is on, and inventing one would be a guess. The default is + // at least a documented number. + Some(dr_types::settings::DEFAULT_ORIGINAL_BUDGET_BYTES) + }; + }); + notify(&ctl.snapshot()); + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + let notify = on_budget_changed.clone(); + window.on_settings_thumbnail_budget_changed(move |text| { + let Some(w) = weak.upgrade() else { return }; + if let Some(bytes) = budget::from_gb(&text) { + ctl.edit(|s| s.cache.thumbnail_budget_bytes = Some(bytes)); + notify(&ctl.snapshot()); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + let notify = on_budget_changed.clone(); + window.on_settings_thumbnail_unlimited_toggled(move |unlimited| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| { + s.cache.thumbnail_budget_bytes = if unlimited { + None + } else { + Some(dr_types::settings::DEFAULT_THUMBNAIL_BUDGET_BYTES) + }; + }); + notify(&ctl.snapshot()); + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_keep_opened_toggled(move |on| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| s.cache.keep_opened_originals = on); + render(&w, &ctl); + }); + } + + // --- export -------------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_format_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(f) = ExportFormat::ALL.get(i as usize).copied() { + ctl.edit(|s| s.export.format = f); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_quality_changed(move |q| { + let Some(w) = weak.upgrade() else { return }; + // Cast before clamping: a negative from the control would wrap to a + // large `u8` and land on 100 instead of the floor. + ctl.edit(|s| s.export.quality = q.clamp(1, 100) as u8); + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_colour_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(c) = ColourSpace::ALL.get(i as usize).copied() { + ctl.edit(|s| s.export.colour_space = c); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_sizing_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(mode) = SizingMode::CHOICES.get(i as usize).copied() { + // Keeps the number the user already typed when they move + // between two sized modes: switching long edge to short edge + // at 900px means 900 on the other axis, not back to 1600. + ctl.edit(|s| { + s.export.sizing = match s.export.sizing.value() { + Some(v) => mode.with_value(v), + None => mode, + }; + }); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_sizing_value_changed(move |text| { + let Some(w) = weak.upgrade() else { return }; + match text.trim().parse::() { + Ok(v) if v > 0 => ctl.edit(|s| s.export.sizing = s.export.sizing.with_value(v)), + _ => log::debug!("ignoring an unusable export size: {text:?}"), + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_upscaling_toggled(move |on| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| s.export.allow_upscaling = on); + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_sharpening_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(x) = OutputSharpening::ALL.get(i as usize).copied() { + ctl.edit(|s| s.export.sharpening = x); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_template_changed(move |text| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| s.export.filename_template = text.to_string()); + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_collision_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(c) = CollisionPolicy::ALL.get(i as usize).copied() { + ctl.edit(|s| s.export.collision = c); + } + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_strip_location_toggled(move |on| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| s.export.strip_location = on); + render(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_destination_changed(move |text| { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| s.export.destination = text.to_string()); + render(&w, &ctl); + }); + } + + // --- reset --------------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = controller.clone(); + let notify = on_budget_changed.clone(); + window.on_settings_reset(move || { + let Some(w) = weak.upgrade() else { return }; + ctl.edit(|s| *s = Settings::default()); + notify(&ctl.snapshot()); + render(&w, &ctl); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A controller writing to a scratch file, with no window attached. + /// + /// The edit-and-persist path is what is worth testing here and it needs no + /// display server; `render` and `wire` are the parts that need a window, + /// and they only move values. + fn controller(name: &str) -> Rc { + let dir = std::env::temp_dir().join(format!( + "dr-settings-ui-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let store = SettingsStore::open_at(dir.join("settings.json")); + Rc::new(SettingsController { + settings: RefCell::new(store.load()), + store, + error: RefCell::new(None), + usage_label: RefCell::new(String::new()), + }) + } + + #[test] + fn an_edit_reaches_the_file_without_a_save_button() { + let ctl = controller("autosave"); + ctl.edit(|s| s.export.quality = 60); + assert_eq!(ctl.store.load().export.quality, 60); + } + + #[test] + fn an_edit_is_sanitised_before_it_is_stored() { + let ctl = controller("sanitise"); + ctl.edit(|s| s.export.quality = 200); + assert_eq!(ctl.snapshot().export.quality, 100); + assert_eq!(ctl.store.load().export.quality, 100); + } + + #[test] + fn two_edits_do_not_overwrite_each_other() { + // The reason `edit` takes a closure: a caller holding a whole + // `Settings` would write back its own stale copy of every other field. + let ctl = controller("independent"); + ctl.edit(|s| s.export.quality = 70); + ctl.edit(|s| s.export.format = ExportFormat::Png); + + let stored = ctl.store.load(); + assert_eq!(stored.export.quality, 70); + assert_eq!(stored.export.format, ExportFormat::Png); + } + + #[test] + fn a_reset_restores_the_defaults_and_persists_them() { + let ctl = controller("reset"); + ctl.edit(|s| { + s.export.quality = 10; + s.cache.original_budget_bytes = None; + }); + ctl.edit(|s| *s = Settings::default()); + + assert_eq!(ctl.store.load(), Settings::default()); + } + + #[test] + fn switching_between_sized_modes_keeps_the_typed_number() { + let ctl = controller("sizing"); + ctl.edit(|s| s.export.sizing = SizingMode::LongEdge(900)); + // What `on_settings_sizing_picked` does for a sized target. + ctl.edit(|s| { + s.export.sizing = match s.export.sizing.value() { + Some(v) => SizingMode::ShortEdge(0).with_value(v), + None => SizingMode::ShortEdge(1600), + } + }); + assert_eq!(ctl.snapshot().export.sizing, SizingMode::ShortEdge(900)); + } + + #[test] + fn a_write_failure_is_recorded_rather_than_swallowed() { + // The page's contract is that what it shows is saved, so a failed + // write has to be visible. + let ctl = controller("unwritable"); + // A path whose parent is a *file* cannot be created as a directory. + let blocker = ctl.store.path().with_file_name("blocker"); + std::fs::write(&blocker, b"not a directory").unwrap(); + let broken = SettingsController { + settings: RefCell::new(Settings::default()), + store: SettingsStore::open_at(blocker.join("settings.json")), + error: RefCell::new(None), + usage_label: RefCell::new(String::new()), + }; + + broken.edit(|s| s.export.quality = 50); + assert!(broken.error.borrow().is_some(), "the failure was silent"); + } + + #[test] + fn a_successful_write_clears_an_earlier_error() { + let ctl = controller("clears"); + *ctl.error.borrow_mut() = Some("stale".to_string()); + ctl.edit(|s| s.export.quality = 80); + assert_eq!(*ctl.error.borrow(), None); + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index ef79a12..812c9de 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -4,6 +4,7 @@ import { LaunchScreen } from "launch.slint"; import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint"; import { CollectionsPanel, CollectionRow } from "collections.slint"; +import { SettingsPage } from "settings.slint"; export { LibraryCell, TimelineBar, CollectionRow } @@ -23,6 +24,7 @@ component StatusBar inherits Rectangle { in property can-return-to-library: false; callback back-to-library(); + callback open-settings(); // Tall enough for a Button to sit in without the strip growing: the // control height and this strip are both 28px by design. @@ -59,6 +61,14 @@ component StatusBar inherits Rectangle { Caption { text: root.layout-class; } + // Reachable from develop as well as from the grid: export defaults are + // most likely to be wanted with a finished photograph on screen, which + // is exactly where this bar is and the library header is not. + Button { + text: "Settings"; + clicked => { root.open-settings(); } + } + // Degraded performance, so this is a caution rather than an active // state: the frame rate has fallen below what the compositing path is // supposed to sustain, which is exactly the assumption A1 exists to @@ -258,6 +268,12 @@ export component AppWindow inherits Window { callback library-toggle-local-only(); callback library-retry-connection(); + // --- pinning a collection offline (FR-NC-6a) --- + in property library-scope-pinned: false; + in property library-pin-done: 0; + in property library-pin-total: 0; + callback library-toggle-pin-scope(); + in property library-root-label: ""; in-out property <[TimelineBar]> library-timeline; in property library-timeline-label: ""; @@ -403,6 +419,69 @@ export component AppWindow inherits Window { callback curve-reset(int); callback reset-all(); + // --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) --- + // + // A page rather than an overlay, and the outermost of the view conditions + // below: it is reachable from the library and from develop, so guarding it + // with `!show-library` or `!show-launch` would make which one you came from + // decide whether it appears. + // + // Every control saves on change (see `settings_ui.rs`), so there is no + // dirty state here and nothing to confirm on the way out. + in-out property show-settings: false; + + in property settings-original-budget: ""; + in property settings-original-unlimited: false; + in property settings-thumbnail-budget: ""; + in property settings-thumbnail-unlimited: false; + in property settings-keep-opened: true; + in property settings-cache-usage: ""; + + callback settings-original-budget-changed(string); + callback settings-original-unlimited-toggled(bool); + callback settings-thumbnail-budget-changed(string); + callback settings-thumbnail-unlimited-toggled(bool); + callback settings-keep-opened-toggled(bool); + + in property <[string]> settings-format-labels; + in property settings-format-selected: 0; + in property settings-quality: 90; + in property settings-quality-enabled: true; + in property <[string]> settings-colour-labels; + in property settings-colour-selected: 0; + in property <[string]> settings-sizing-labels; + in property settings-sizing-selected: 0; + in property settings-sizing-value: 0; + in property settings-sizing-has-value: false; + in property settings-sizing-unit: "px"; + in property settings-allow-upscaling: false; + in property <[string]> settings-sharpening-labels; + in property settings-sharpening-selected: 0; + in property settings-filename-template: ""; + in property <[string]> settings-collision-labels; + in property settings-collision-selected: 0; + in property settings-strip-location: true; + in property settings-destination: ""; + in property settings-error: ""; + + callback settings-format-picked(int); + callback settings-quality-changed(int); + callback settings-colour-picked(int); + callback settings-sizing-picked(int); + callback settings-sizing-value-changed(string); + callback settings-upscaling-toggled(bool); + callback settings-sharpening-picked(int); + callback settings-template-changed(string); + callback settings-collision-picked(int); + callback settings-strip-location-toggled(bool); + callback settings-destination-changed(string); + callback settings-reset(); + + /// Show the settings page. Reads the file first, so a second instance's + /// writes are picked up rather than overwritten. + callback settings-open(); + callback settings-close(); + // FR-UI-1: layout class follows window width, not device type. A narrow // desktop window gets the compact layout, exactly as a tablet would. // @@ -418,9 +497,69 @@ export component AppWindow inherits Window { // One-way: report width outward, never read layout back into it. changed width => { root.window-resized(self.width); } + // Settings, over everything. First in the file and first in z-order so the + // conditions below can be read as "and settings is not open". + if root.show-settings: SettingsPage { + width: 100%; + height: 100%; + + original-budget: root.settings-original-budget; + original-unlimited: root.settings-original-unlimited; + thumbnail-budget: root.settings-thumbnail-budget; + thumbnail-unlimited: root.settings-thumbnail-unlimited; + keep-opened: root.settings-keep-opened; + cache-usage: root.settings-cache-usage; + + original-budget-changed(t) => { root.settings-original-budget-changed(t); } + original-unlimited-toggled(on) => { + root.settings-original-unlimited-toggled(on); + } + thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); } + thumbnail-unlimited-toggled(on) => { + root.settings-thumbnail-unlimited-toggled(on); + } + keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); } + + format-labels: root.settings-format-labels; + format-selected: root.settings-format-selected; + quality: root.settings-quality; + quality-enabled: root.settings-quality-enabled; + colour-labels: root.settings-colour-labels; + colour-selected: root.settings-colour-selected; + sizing-labels: root.settings-sizing-labels; + sizing-selected: root.settings-sizing-selected; + sizing-value: root.settings-sizing-value; + sizing-has-value: root.settings-sizing-has-value; + sizing-unit: root.settings-sizing-unit; + allow-upscaling: root.settings-allow-upscaling; + sharpening-labels: root.settings-sharpening-labels; + sharpening-selected: root.settings-sharpening-selected; + filename-template: root.settings-filename-template; + collision-labels: root.settings-collision-labels; + collision-selected: root.settings-collision-selected; + strip-location: root.settings-strip-location; + destination: root.settings-destination; + error: root.settings-error; + + format-picked(i) => { root.settings-format-picked(i); } + quality-changed(q) => { root.settings-quality-changed(q); } + colour-picked(i) => { root.settings-colour-picked(i); } + sizing-picked(i) => { root.settings-sizing-picked(i); } + sizing-value-changed(t) => { root.settings-sizing-value-changed(t); } + upscaling-toggled(on) => { root.settings-upscaling-toggled(on); } + sharpening-picked(i) => { root.settings-sharpening-picked(i); } + template-changed(t) => { root.settings-template-changed(t); } + collision-picked(i) => { root.settings-collision-picked(i); } + strip-location-toggled(on) => { root.settings-strip-location-toggled(on); } + destination-changed(t) => { root.settings-destination-changed(t); } + + close() => { root.settings-close(); } + reset-defaults() => { root.settings-reset(); } + } + // The launch screen replaces the whole window rather than overlaying it: // there is no library to look at until an account is configured. - if root.show-launch: LaunchScreen { + if !root.show-settings && root.show-launch: LaunchScreen { width: 100%; height: 100%; signed-in: root.launch-signed-in; @@ -457,7 +596,7 @@ export component AppWindow inherits Window { // has been opened but no image chosen yet. The collections sidebar and the // grid are siblings here rather than the sidebar living inside the grid, // because the drag that connects them has to be owned above both. - if !root.show-launch && root.show-library: Rectangle { + if !root.show-settings && !root.show-launch && root.show-library: Rectangle { background: Theme.ground; HorizontalLayout { @@ -518,6 +657,11 @@ export component AppWindow inherits Window { local-only: root.library-local-only; local-count: root.library-local-count; toggle-local-only() => { root.library-toggle-local-only(); } + + scope-pinned: root.library-scope-pinned; + pin-done: root.library-pin-done; + pin-total: root.library-pin-total; + toggle-pin-scope() => { root.library-toggle-pin-scope(); } root-label: root.library-root-label; timeline: root.library-timeline; timeline-label: root.library-timeline-label; @@ -554,6 +698,7 @@ export component AppWindow inherits Window { cell-clicked(i) => { root.library-cell-clicked(i); } rescan() => { root.library-rescan(); } change-library() => { root.library-change(); } + open-settings() => { root.settings-open(); } cell-pressed(i, ctrl, shift) => { root.library-cell-pressed(i, ctrl, shift); @@ -600,7 +745,7 @@ export component AppWindow inherits Window { } } - if !root.show-launch && !root.show-library: VerticalLayout { + if !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout { StatusBar { adapter: root.adapter; backend: root.backend; @@ -612,6 +757,7 @@ export component AppWindow inherits Window { // grid to return to. can-return-to-library: root.library-total > 0; back-to-library() => { root.back-to-library(); } + open-settings() => { root.settings-open(); } } HorizontalLayout { diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index e104ee4..dd2ac31 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -583,6 +583,8 @@ export component LibraryGrid inherits Rectangle { callback rescan(); /// Back to the launch screen, to change library or account. callback change-library(); + /// Open the settings page. + callback open-settings(); // --- selection and drag --- // @@ -646,6 +648,17 @@ export component LibraryGrid inherits Rectangle { in property offline-reason: ""; in property offline-since: ""; callback retry-connection(); + + // --- pinning (FR-NC-6a) ----------------------------------------------- + // + // Whether the collection the grid is scoped to is kept offline, and how + // far the download has got. Progress is shown because pinning a trip is + // gigabytes of transfer — a button that appeared to do nothing for twenty + // minutes would read as broken. + in property scope-pinned: false; + in property pin-done: 0; + in property pin-total: 0; + callback toggle-pin-scope(); /// Narrow to images whose RAW is stored locally — the ones openable now. in property local-only: false; in property local-count: 0; @@ -767,6 +780,19 @@ export component LibraryGrid inherits Rectangle { clicked => { root.change-library(); } } + // TRACES: FR-NC-6a + // Keep this collection offline. Only offered when the grid is + // scoped to one: "pin the whole library" is a different and + // much more expensive request, and a button that meant either + // depending on invisible state would be a trap. + if root.scope-label != "": Button { + text: root.scope-pinned ? "Pinned ✓" : "Pin offline"; + active: root.scope-pinned; + enabled: !root.scanning; + y: (parent.height - self.height) / 2; + clicked => { root.toggle-pin-scope(); } + } + Button { // Shares the finished index so a second device inherits it // rather than repeating hours of range fetches. @@ -782,6 +808,16 @@ export component LibraryGrid inherits Rectangle { visible: !root.scanning; clicked => { root.rescan(); } } + + // Last in the row, and unconditional. The buttons before it + // come and go with what the grid is showing; settings is + // always reachable, and a control that moved as its + // neighbours appeared would be hunted for each time. + Button { + text: "Settings"; + y: (parent.height - self.height) / 2; + clicked => { root.open-settings(); } + } } } @@ -916,6 +952,38 @@ export component LibraryGrid inherits Rectangle { : 0; } + // --- pinning ------------------------------------------------------ + // + // Its own line rather than the shared progress bar above: that one is + // driven by the scan and the sweep, and a pin runs alongside both. + if root.pin-total > 0: Rectangle { + height: 34px; + background: Theme.surface; + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: Theme.gap; + + Caption { + text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total; + vertical-alignment: center; + } + + ProgressBar { + fraction: root.pin-done / max(1, root.pin-total); + y: (parent.height - self.height) / 2; + horizontal-stretch: 1; + } + } + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; + } + } + // --- offline ------------------------------------------------------ // // Above the scan error, and it suppresses it: when the server is diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint new file mode 100644 index 0000000..294a717 --- /dev/null +++ b/ui/dr-ui/ui/settings.slint @@ -0,0 +1,581 @@ +import { Theme } from "theme.slint"; +import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Section } from "widgets.slint"; + +// Settings: how much disk the app may spend, and what an export defaults to. +// +// A full-window page rather than a modal dialogue. Two reasons, and the second +// is the load-bearing one. A modal has to be dismissed to check anything it +// refers to, and these settings refer to the library constantly — how full the +// cache is, where exports land. And every control here saves on change +// (see `settings_ui.rs`), so there is no OK/Cancel pair for a modal to host; +// a dialogue frame with only a close button is a window pretending to be a +// decision. +// +// It replaces the view rather than overlaying it because the app already +// switches views this way — launch, library, develop — and an overlay would be +// a fourth mechanism for the same job. + +// One choice out of several, drawn as a row of chips. +// +// Not a dropdown. Every choice set on this page is short and the options are +// worth reading side by side — a photographer picking an output colour space +// benefits from seeing that ProPhoto exists next to sRGB, which a collapsed +// menu hides behind a click. `FilterChip` in widgets.slint is the same idea for +// the library's rating filter, but it carries a count and a filter's +// on-off semantics; this is single-selection over a fixed list, so the +// behaviour differs where it matters. +component ChoiceChip inherits Rectangle { + in property label; + in property selected: false; + in property enabled: true; + callback clicked(); + + height: Theme.control-height; + // Wide enough that a one-word label is still a comfortable target, which + // is what `control-min-width` exists for — but chips sit several to a row, + // so they take their own narrower floor rather than the button's. + min-width: 64px; + border-radius: Theme.radius; + border-width: 1px; + border-color: root.selected ? Theme.active : Theme.rule; + // The selected chip fills, matching the checked box and the slider fill: + // `active` is the token for an engaged control, and this is the one chip in + // the row that is engaged. + background: !root.enabled ? transparent + : (root.selected ? Theme.active + : (touch.pressed ? Theme.pressed + : (touch.has-hover ? Theme.hover : transparent))); + opacity: root.enabled ? 1.0 : 0.4; + + touch := TouchArea { + // FR-UI-3: the drawn chip is `control-height`, so the target grows + // past its own bounds rather than the ink growing. + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + enabled: root.enabled; + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + Text { + text: root.label; + // Dark on the fill: `active` is near-white and ink on it is invisible. + color: root.selected ? Theme.ground : Theme.ink; + font-size: Theme.text; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } +} + +// A labelled row of chips, with the label above rather than beside. +// +// Above, because the chip rows are wide and a left-hand label column would +// either crush them or leave the page half empty at narrow widths. Stacked, +// every row uses the full width at any window size (FR-UI-1). +component ChoiceRow inherits VerticalLayout { + in property label; + in property hint; + in property <[string]> options; + in property selected: 0; + in property enabled: true; + callback picked(int); + + spacing: 4px; + + HorizontalLayout { + spacing: Theme.gap; + Label { text: root.label; body: true; } + Caption { + text: root.hint; + horizontal-alignment: right; + horizontal-stretch: 1; + overflow: elide; + } + } + + HorizontalLayout { + spacing: Theme.gap-sm; + alignment: start; + + for option[i] in root.options: ChoiceChip { + label: option; + selected: i == root.selected; + enabled: root.enabled; + clicked => { root.picked(i); } + } + } +} + +// A switch: one setting that is either on or off. +// +// The tick-box shape is `FormatCheck`'s from launch.slint, which is the +// established idiom for a boolean in this codebase. Reproduced rather than +// shared because that one is private to the launch screen and lives inside its +// format list; lifting it into widgets.slint would be the better move once a +// third caller appears, and doing it for the second is how a component ends up +// with parameters for every caller's variation. +component Switch inherits Rectangle { + in property label; + in property hint; + in-out property checked; + callback toggled(bool); + + height: max(row.preferred-height, Theme.control-height); + + touch := TouchArea { + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + clicked => { + root.checked = !root.checked; + root.toggled(root.checked); + } + } + + row := HorizontalLayout { + spacing: Theme.gap; + alignment: start; + + Rectangle { + width: 18px; + height: 18px; + y: (parent.height - self.height) / 2; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: root.checked ? Theme.active : Theme.rule; + background: root.checked ? Theme.active : transparent; + + Text { + text: "✓"; + color: Theme.ground; + font-size: 12px; + visible: root.checked; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + } + + VerticalLayout { + spacing: 1px; + alignment: center; + Label { text: root.label; body: true; emphasised: touch.has-hover; } + // The hint carries *why* a default is what it is, for the settings + // where that is not obvious from the name — upscaling being off, + // location being stripped. A page of bare switches makes the user + // guess at the consequence of each. + Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; } + } + } +} + +// A text entry with its label above and an optional unit after it. +// +// `Field` is `touch-target` tall and stretches, which is right for a server +// URL on the launch screen and wrong for a byte count — so this constrains the +// width rather than restyling the field. +component EntryRow inherits VerticalLayout { + in property label; + in property hint; + in-out property text; + in property unit; + in property placeholder; + in property enabled: true; + in property field-width: 140px; + callback accepted(string); + + spacing: 4px; + + HorizontalLayout { + spacing: Theme.gap; + Label { text: root.label; body: true; } + Caption { + text: root.hint; + horizontal-alignment: right; + horizontal-stretch: 1; + overflow: elide; + } + } + + HorizontalLayout { + spacing: Theme.gap-sm; + alignment: start; + + Rectangle { + width: root.field-width; + height: field.preferred-height; + opacity: root.enabled ? 1.0 : 0.4; + + field := Field { + width: 100%; + text <=> root.text; + placeholder: root.placeholder; + // Committed on Enter *and* on losing focus. Enter alone loses + // an edit the moment the user clicks the next control, which + // on a page that saves continuously reads as the setting not + // having taken. + accepted(t) => { root.accepted(t); } + } + + // `Field` reports focus but does not signal losing it, so the + // change is watched here. + property focused: field.has-focus; + changed focused => { + if (!self.focused) { + root.accepted(root.text); + } + } + } + + Label { + text: root.unit; + visible: root.unit != ""; + vertical-alignment: center; + height: Theme.touch-target; + } + } +} + +export component SettingsPage inherits Rectangle { + // --- cache --------------------------------------------------------- + in-out property original-budget; + in property original-unlimited: false; + in-out property thumbnail-budget; + in property thumbnail-unlimited: false; + in property keep-opened: true; + /// What the cache currently holds. Empty hides the line. + in property cache-usage; + + callback original-budget-changed(string); + callback original-unlimited-toggled(bool); + callback thumbnail-budget-changed(string); + callback thumbnail-unlimited-toggled(bool); + callback keep-opened-toggled(bool); + + // --- export -------------------------------------------------------- + in property <[string]> format-labels; + in property format-selected: 0; + in property quality: 90; + in property quality-enabled: true; + in property <[string]> colour-labels; + in property colour-selected: 0; + in property <[string]> sizing-labels; + in property sizing-selected: 0; + in-out property sizing-value: 0; + in property sizing-has-value: false; + in property sizing-unit: "px"; + in property allow-upscaling: false; + in property <[string]> sharpening-labels; + in property sharpening-selected: 0; + in-out property filename-template; + in property <[string]> collision-labels; + in property collision-selected: 0; + in property strip-location: true; + in-out property destination; + + callback format-picked(int); + callback quality-changed(int); + callback colour-picked(int); + callback sizing-picked(int); + callback sizing-value-changed(string); + callback upscaling-toggled(bool); + callback sharpening-picked(int); + callback template-changed(string); + callback collision-picked(int); + callback strip-location-toggled(bool); + callback destination-changed(string); + + /// A save failed. The page's whole contract is that what it shows is + /// stored, so this cannot be swallowed. + in property error; + + callback close(); + callback reset-defaults(); + + background: Theme.ground; + + VerticalLayout { + // --- header ---------------------------------------------------- + // + // 44px and `surface`, matching the library's header exactly: this is + // the same kind of bar in the same place, and a page that drew its own + // height would read as a different application. + Rectangle { + height: 44px; + background: Theme.surface; + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: Theme.gap; + + Button { + text: "‹ Back"; + y: (parent.height - self.height) / 2; + clicked => { root.close(); } + } + + Value { text: "Settings"; } + + Rectangle { horizontal-stretch: 1; } + + Caption { + // Says where the file is, because a settings page that + // saves silently gives the user nothing to point a backup + // or a support question at. + text: "Saved automatically"; + vertical-alignment: center; + } + + Button { + text: "Reset to defaults"; + y: (parent.height - self.height) / 2; + clicked => { root.reset-defaults(); } + } + } + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; + } + } + + // A failed write, above the content: it applies to everything below + // and the user needs it before they keep editing into a file that is + // not being written. + if root.error != "": Rectangle { + height: 32px; + background: Theme.surface; + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + Caption { text: root.error; warn: true; overflow: elide; } + } + } + + Flickable { + vertical-stretch: 1; + viewport-height: content.preferred-height; + + content := VerticalLayout { + width: 100%; + padding: Theme.gap-lg; + spacing: Theme.gap-lg; + alignment: start; + + // The column is capped rather than filling the window. A + // settings form stretched across a 2560px display puts its + // label at one edge and its control at the other; 680px is + // about 90 characters of `text`, which is a readable measure. + // `min` so a narrow window still uses what it has (FR-UI-1). + property column: min(root.width - 2 * Theme.gap-lg, 680px); + + // --- storage --------------------------------------------- + Rectangle { + width: content.column; + height: storage.preferred-height; + + storage := Panel { + width: 100%; + + PanelHeading { text: "STORAGE"; } + + Caption { + text: "How much of this device's disk DarkRoom may use. " + + "These are per-device and never travel with the library."; + wrap: word-wrap; + } + + // What is actually held, before what is allowed: + // a ceiling means nothing without the current figure + // to judge it against. + if root.cache-usage != "": Value { + text: root.cache-usage; + compact: true; + } + + Rectangle { height: Theme.gap-sm; } + + EntryRow { + label: "Cached originals"; + hint: "evicted oldest-first when full"; + text <=> root.original-budget; + enabled: !root.original-unlimited; + placeholder: "8.0 GB"; + accepted(t) => { root.original-budget-changed(t); } + } + + Switch { + label: "No limit on cached originals"; + hint: "Nothing is ever evicted for space. " + + "Pinned photographs are kept regardless."; + checked: root.original-unlimited; + toggled(on) => { root.original-unlimited-toggled(on); } + } + + Rectangle { height: Theme.gap-sm; } + + EntryRow { + label: "Thumbnails and previews"; + hint: "what the grid draws from"; + text <=> root.thumbnail-budget; + enabled: !root.thumbnail-unlimited; + placeholder: "2.0 GB"; + accepted(t) => { root.thumbnail-budget-changed(t); } + } + + Switch { + label: "No limit on thumbnails"; + checked: root.thumbnail-unlimited; + toggled(on) => { root.thumbnail-unlimited-toggled(on); } + } + + Rectangle { height: Theme.gap-sm; } + + Switch { + label: "Keep originals after opening them"; + hint: "The file was downloaded anyway, so keeping it " + + "costs no bandwidth and saves the transfer next time."; + checked: root.keep-opened; + toggled(on) => { root.keep-opened-toggled(on); } + } + } + } + + // --- export ---------------------------------------------- + Rectangle { + width: content.column; + height: export-panel.preferred-height; + + export-panel := Panel { + width: 100%; + spacing: Theme.gap; + + PanelHeading { text: "EXPORT DEFAULTS"; } + + Caption { + text: "What an export starts from. Every one of these " + + "is still changeable per export."; + wrap: word-wrap; + } + + ChoiceRow { + label: "Format"; + options: root.format-labels; + selected: root.format-selected; + picked(i) => { root.format-picked(i); } + } + + EntryRow { + label: "Quality"; + // Says why it is greyed rather than leaving the + // user to work out that PNG has no quality. + hint: root.quality-enabled ? "1 to 100" + : "the chosen format is lossless"; + text: root.quality; + enabled: root.quality-enabled; + field-width: 90px; + accepted(t) => { root.quality-changed(t.to-float()); } + } + + ChoiceRow { + label: "Colour space"; + hint: "profile embedded on export"; + options: root.colour-labels; + selected: root.colour-selected; + picked(i) => { root.colour-picked(i); } + } + + ChoiceRow { + label: "Size"; + options: root.sizing-labels; + selected: root.sizing-selected; + picked(i) => { root.sizing-picked(i); } + } + + // Only where the chosen mode carries a number: + // "Original" has none, and a field showing 0 beside it + // would invite the reading "zero pixels". + if root.sizing-has-value: EntryRow { + label: "Size value"; + text: root.sizing-value; + unit: root.sizing-unit; + field-width: 90px; + accepted(t) => { root.sizing-value-changed(t); } + } + + Switch { + label: "Allow upscaling"; + hint: "Off, a request larger than the source exports " + + "at source size rather than failing."; + checked: root.allow-upscaling; + toggled(on) => { root.upscaling-toggled(on); } + } + + ChoiceRow { + label: "Output sharpening"; + hint: "scaled by the resize factor"; + options: root.sharpening-labels; + selected: root.sharpening-selected; + picked(i) => { root.sharpening-picked(i); } + } + } + } + + // --- naming and destination ------------------------------ + // + // Its own panel rather than more of the export one: format and + // size describe the image, these describe the file. The export + // panel was long enough that the boundary was worth drawing. + Rectangle { + width: content.column; + height: naming.preferred-height; + + naming := Panel { + width: 100%; + spacing: Theme.gap; + + PanelHeading { text: "FILES AND METADATA"; } + + EntryRow { + label: "Filename template"; + hint: "{name} {seq} {date} {dimensions} {preset}"; + text <=> root.filename-template; + field-width: 260px; + placeholder: "{name}"; + accepted(t) => { root.template-changed(t); } + } + + ChoiceRow { + label: "If the file exists"; + options: root.collision-labels; + selected: root.collision-selected; + picked(i) => { root.collision-picked(i); } + } + + EntryRow { + label: "Destination"; + hint: "empty asks each time"; + text <=> root.destination; + field-width: 320px; + placeholder: "Ask each time"; + accepted(t) => { root.destination-changed(t); } + } + + Switch { + label: "Strip location and personal metadata"; + hint: "On. An export is usually the copy that leaves " + + "this machine, and a location embedded in a " + + "published photograph cannot be recalled."; + checked: root.strip-location; + toggled(on) => { root.strip-location-toggled(on); } + } + } + } + } + } + } +}