Keep originals on this device, by pin and by use
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+19
-7
@@ -1452,6 +1452,7 @@ name = "dr-types"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1479,6 +1480,7 @@ dependencies = [
|
|||||||
"serde_norway",
|
"serde_norway",
|
||||||
"slint",
|
"slint",
|
||||||
"slint-build",
|
"slint-build",
|
||||||
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
@@ -5202,9 +5204,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5237,6 +5239,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5440,13 +5443,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-platform-verifier"
|
name = "rustls-platform-verifier"
|
||||||
version = "0.7.0"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni 0.22.4",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -6960,9 +6963,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -7151,6 +7154,15 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"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]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
+12
-4
@@ -64,10 +64,18 @@ pollster = "0.4"
|
|||||||
# cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid.
|
# 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.
|
# 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
|
# `rustls-tls-webpki-roots-no-provider` rather than plain `rustls-no-provider`:
|
||||||
# unless initialised from Kotlin (spike S3). D7 records `tls_certs_only` plus
|
# the latter verifies against rustls-platform-verifier, which reaches the
|
||||||
# webpki-roots as the escape hatch.
|
# Android trust store over JNI and panics mid-handshake unless initialised from
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream", "json"] }
|
# 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"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
quick-xml = "0.41"
|
quick-xml = "0.41"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||||
|
|||||||
@@ -11,6 +11,17 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="paris.tourolle.darkroom">
|
package="paris.tourolle.darkroom">
|
||||||
|
|
||||||
|
<!-- Everything the app does with a server needs this: Login Flow v2, the
|
||||||
|
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||||
|
socket creation outright, and the failure is invisible — no panic to
|
||||||
|
catch, no log line, just a worker thread that stops. Storage is the
|
||||||
|
separate case that genuinely needs no permission here, because SAF
|
||||||
|
grants per-tree at runtime (ARCH §6.9). -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||||
|
work on unmetered-and-charging, which means knowing the network type. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||||
fails at install rather than at first frame. -->
|
fails at install rather than at first frame. -->
|
||||||
|
|||||||
@@ -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<u64>);
|
||||||
|
|
||||||
|
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<u64> {
|
||||||
|
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<i64>,
|
||||||
|
pub pinned: bool,
|
||||||
|
/// Path relative to the cache directory.
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Self, CatalogError> {
|
||||||
|
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<Option<Vec<u8>>, CatalogError> {
|
||||||
|
let path: Option<String> = 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<usize, CatalogError> {
|
||||||
|
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<usize, CatalogError> {
|
||||||
|
self.set_pinned(conn, images, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_pinned(
|
||||||
|
&self,
|
||||||
|
conn: &Connection,
|
||||||
|
images: &[ImageId],
|
||||||
|
pinned: bool,
|
||||||
|
) -> Result<usize, CatalogError> {
|
||||||
|
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<Vec<ImageId>, 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::<Result<Vec<_>, _>>()?;
|
||||||
|
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<Usage, CatalogError> {
|
||||||
|
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<usize, CatalogError> {
|
||||||
|
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<String>>(2)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
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<Vec<Entry>, 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::<Result<Vec<_>, _>>()?;
|
||||||
|
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<ImageId>) {
|
||||||
|
fixture_with(n, Budget::bytes(1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_with(n: usize, budget: Budget) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
|
||||||
|
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"[..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ use std::path::Path;
|
|||||||
use dr_types::{Availability, ImageId};
|
use dr_types::{Availability, ImageId};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
@@ -41,6 +42,7 @@ pub mod schema;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod trash;
|
pub mod trash;
|
||||||
|
|
||||||
|
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||||
pub use collections::{Collection, CollectionKind, TreeRow};
|
pub use collections::{Collection, CollectionKind, TreeRow};
|
||||||
pub use error::CatalogError;
|
pub use error::CatalogError;
|
||||||
pub use jobs::{Job, JobKind, Priority};
|
pub use jobs::{Job, JobKind, Priority};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// 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`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -60,6 +60,12 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.pragma_update(None, "user_version", 4)?;
|
tx.pragma_update(None, "user_version", 4)?;
|
||||||
tx.commit()?;
|
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)
|
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#"
|
const V4: &str = r#"
|
||||||
-- TRACES: FR-CAT-15
|
-- TRACES: FR-CAT-15
|
||||||
-- Soft delete. A trashed image is a real file that has been *moved* to a trash
|
-- 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);
|
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]
|
#[test]
|
||||||
fn stems_ignore_directories_containing_dots() {
|
fn stems_ignore_directories_containing_dots() {
|
||||||
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
|
assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1");
|
||||||
|
|||||||
@@ -510,6 +510,24 @@ pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
|||||||
install_crypto_provider();
|
install_crypto_provider();
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.user_agent(user_agent.to_string())
|
.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()
|
.build()
|
||||||
.map_err(|e| RemoteError::Network(e.to_string()))
|
.map_err(|e| RemoteError::Network(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,9 @@ thiserror.workspace = true
|
|||||||
# sync'd cache rules, so the predicate language has to serialise. Derive-only:
|
# sync'd cache rules, so the predicate language has to serialise. Derive-only:
|
||||||
# no serde machinery leaks into the rest of `core/`.
|
# no serde machinery leaks into the rest of `core/`.
|
||||||
serde.workspace = true
|
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
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ use std::fmt;
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
pub mod selector;
|
pub mod selector;
|
||||||
|
pub mod settings;
|
||||||
|
|
||||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
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
|
/// Identifies a granted library location — a directory on Linux, a persisted
|
||||||
/// document tree on Android.
|
/// document tree on Android.
|
||||||
|
|||||||
@@ -232,7 +232,6 @@ mod tests {
|
|||||||
assert_eq!(found, vec![CollectionId(1), CollectionId(2)]);
|
assert_eq!(found, vec![CollectionId(1), CollectionId(2)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stored_tiers_round_trip() {
|
fn stored_tiers_round_trip() {
|
||||||
// These integers are on disk. A reordering of the variants that broke
|
// These integers are on disk. A reordering of the variants that broke
|
||||||
|
|||||||
@@ -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<u64>,
|
||||||
|
|
||||||
|
/// 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<u64>,
|
||||||
|
|
||||||
|
/// 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<u32> {
|
||||||
|
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<u64> {
|
||||||
|
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<u64>) -> 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::<Settings>(&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::<Settings>("{}").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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-36
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 82 |
|
| Source files scanned | 92 |
|
||||||
| TRACES tags found | 80 |
|
| TRACES tags found | 130 |
|
||||||
| Requirements defined | 149 |
|
| Requirements defined | 149 |
|
||||||
| Requirements covered | 60 |
|
| Requirements covered | 69 |
|
||||||
| **Coverage** | **40.3%** (60/149) |
|
| **Coverage** | **46.3%** (69/149) |
|
||||||
|
|
||||||
### By type
|
### By type
|
||||||
|
|
||||||
| Type | Covered | Defined |
|
| Type | Covered | Defined |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FR | 44 | 95 |
|
| FR | 53 | 95 |
|
||||||
| NFR | 14 | 48 |
|
| NFR | 14 | 48 |
|
||||||
| R | 2 | 6 |
|
| R | 2 | 6 |
|
||||||
|
|
||||||
@@ -33,21 +33,21 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
|
|||||||
|
|
||||||
| ID | Tagged in |
|
| 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-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:122`](../ui/dr-ui/src/library.rs#L122) |
|
| 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-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-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-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-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-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/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| 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:228`](../ui/dr-ui/src/library.rs#L228) |
|
| 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/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-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-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-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-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-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) |
|
| 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-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-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-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-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-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-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-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-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-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:177`](../core/dr-types/src/lib.rs#L177), [`core/dr-types/src/lib.rs:95`](../core/dr-types/src/lib.rs#L95) |
|
| 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-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-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-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-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-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-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-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-1 | [`ui/dr-ui/src/lib.rs:51`](../ui/dr-ui/src/lib.rs#L51) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) |
|
| 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-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) |
|
| 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) |
|
| 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-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-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-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-1 | [`ui/dr-ui/src/lib.rs:43`](../ui/dr-ui/src/lib.rs#L43) |
|
||||||
| 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-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) |
|
| 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) |
|
| 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) |
|
| R4 | [`core/dr-gpu/src/lib.rs:123`](../core/dr-gpu/src/lib.rs#L123) |
|
||||||
|
|
||||||
## Not yet tagged
|
## 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.
|
||||||
|
|
||||||
<details><summary>Show untagged requirements</summary>
|
<details><summary>Show untagged requirements</summary>
|
||||||
|
|
||||||
@@ -127,24 +136,15 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
|
|||||||
- FR-DSP-6
|
- FR-DSP-6
|
||||||
- FR-DSP-7
|
- FR-DSP-7
|
||||||
- FR-DSP-8
|
- 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-7
|
||||||
- FR-EXP-8
|
|
||||||
- FR-NC-10
|
- FR-NC-10
|
||||||
- FR-NC-11
|
- FR-NC-11
|
||||||
- FR-NC-6
|
- FR-NC-6
|
||||||
- FR-NC-6b
|
- FR-NC-6b
|
||||||
- FR-NC-7
|
|
||||||
- FR-PLAT-AND-2
|
- FR-PLAT-AND-2
|
||||||
- FR-PLAT-AND-4
|
- FR-PLAT-AND-4
|
||||||
- FR-PLAT-AND-5
|
- FR-PLAT-AND-5
|
||||||
- FR-PLAT-AND-6
|
- FR-PLAT-AND-6
|
||||||
- FR-PLAT-LIN-1
|
|
||||||
- FR-PLAT-LIN-2
|
- FR-PLAT-LIN-2
|
||||||
- FR-PLAT-LIN-3
|
- FR-PLAT-LIN-3
|
||||||
- FR-RAW-2
|
- FR-RAW-2
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ rusqlite.workspace = true
|
|||||||
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg"] }
|
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg"] }
|
||||||
wgpu.workspace = true
|
wgpu.workspace = true
|
||||||
anyhow.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
|
log.workspace = true
|
||||||
pollster.workspace = true
|
pollster.workspace = true
|
||||||
# Runtime YAML only for `live-style`; release builds read the tokens the
|
# Runtime YAML only for `live-style`; release builds read the tokens the
|
||||||
|
|||||||
@@ -268,6 +268,16 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
|
|||||||
// as "failed unexpectedly", losing the one piece of information that
|
// as "failed unexpectedly", losing the one piece of information that
|
||||||
// would explain the failure. Catch it and forward the message instead.
|
// would explain the failure. Catch it and forward the message instead.
|
||||||
let panic_tx = tx.clone();
|
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 || {
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||||
// `enable_all()` also enables the signal driver, which wants to own
|
// `enable_all()` also enables the signal driver, which wants to own
|
||||||
// process-wide signal handling and is not something a worker thread
|
// process-wide signal handling and is not something a worker thread
|
||||||
@@ -280,12 +290,13 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
|
|||||||
{
|
{
|
||||||
Ok(rt) => rt,
|
Ok(rt) => rt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(LoginMessage::Failed(e.to_string()));
|
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
step("runtime built");
|
||||||
|
|
||||||
run_login_flow(rt, tx, server);
|
run_login_flow(rt, tx, server, &step);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if let Err(panic) = result {
|
if let Err(panic) = result {
|
||||||
@@ -308,16 +319,19 @@ fn run_login_flow(
|
|||||||
rt: tokio::runtime::Runtime,
|
rt: tokio::runtime::Runtime,
|
||||||
tx: std::sync::mpsc::Sender<LoginMessage>,
|
tx: std::sync::mpsc::Sender<LoginMessage>,
|
||||||
server: String,
|
server: String,
|
||||||
|
step: &dyn Fn(&str),
|
||||||
) {
|
) {
|
||||||
{
|
{
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
|
step("building http client");
|
||||||
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
|
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(LoginMessage::Failed(e.to_string()));
|
let _ = tx.send(LoginMessage::Failed(format!("http client: {e}")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
step("requesting login flow");
|
||||||
|
|
||||||
log::info!("POST {server}/index.php/login/v2");
|
log::info!("POST {server}/index.php/login/v2");
|
||||||
let flow = match auth::begin(&client, &server, "DarkRoom").await {
|
let flow = match auth::begin(&client, &server, "DarkRoom").await {
|
||||||
@@ -361,6 +375,8 @@ fn run_login_flow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum LoginMessage {
|
enum LoginMessage {
|
||||||
|
/// The last step the worker reached, for diagnosis when it dies silently.
|
||||||
|
Progress(String),
|
||||||
AwaitingApproval(String),
|
AwaitingApproval(String),
|
||||||
Success(Box<(dr_sync_nextcloud::AppCredentials, String)>),
|
Success(Box<(dr_sync_nextcloud::AppCredentials, String)>),
|
||||||
Failed(String),
|
Failed(String),
|
||||||
@@ -374,6 +390,9 @@ fn poll_channel(
|
|||||||
) {
|
) {
|
||||||
let timer = slint::Timer::default();
|
let timer = slint::Timer::default();
|
||||||
let ctl_for_cb = ctl.clone();
|
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(
|
timer.start(
|
||||||
slint::TimerMode::Repeated,
|
slint::TimerMode::Repeated,
|
||||||
@@ -398,9 +417,10 @@ fn poll_channel(
|
|||||||
// sending anything, which `catch_unwind` in
|
// sending anything, which `catch_unwind` in
|
||||||
// spawn_login should now prevent — so say that the
|
// spawn_login should now prevent — so say that the
|
||||||
// worker stopped rather than blaming the sign-in.
|
// worker stopped rather than blaming the sign-in.
|
||||||
ctl.model
|
ctl.model.borrow_mut().fail(format!(
|
||||||
.borrow_mut()
|
"the sign-in worker stopped after: {}",
|
||||||
.fail("the sign-in worker stopped without reporting why");
|
last_step.borrow()
|
||||||
|
));
|
||||||
render(&w, ctl);
|
render(&w, ctl);
|
||||||
}
|
}
|
||||||
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
|
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
|
||||||
@@ -412,6 +432,9 @@ fn poll_channel(
|
|||||||
|
|
||||||
let mut done = false;
|
let mut done = false;
|
||||||
match msg {
|
match msg {
|
||||||
|
LoginMessage::Progress(s) => {
|
||||||
|
*last_step.borrow_mut() = s;
|
||||||
|
}
|
||||||
LoginMessage::AwaitingApproval(url) => {
|
LoginMessage::AwaitingApproval(url) => {
|
||||||
ctl.model.borrow_mut().await_approval(url);
|
ctl.model.borrow_mut().await_approval(url);
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-1
@@ -20,6 +20,8 @@ mod develop;
|
|||||||
mod labels;
|
mod labels;
|
||||||
mod library;
|
mod library;
|
||||||
mod library_ui;
|
mod library_ui;
|
||||||
|
mod settings_store;
|
||||||
|
mod settings_ui;
|
||||||
mod trash;
|
mod trash;
|
||||||
#[cfg(live_style)]
|
#[cfg(live_style)]
|
||||||
mod live_style;
|
mod live_style;
|
||||||
@@ -540,6 +542,50 @@ pub fn run(paths: Vec<PathBuf>) -> 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
|
// The device is shared by demosaic and the adjust pass. Without one the
|
||||||
// app still browses through the preview path, just without develop.
|
// app still browses through the preview path, just without develop.
|
||||||
let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
||||||
@@ -827,10 +873,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
return;
|
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");
|
log::info!("fetching {path} for develop");
|
||||||
w.set_load_error("Downloading…".into());
|
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
|
// Polled on the UI thread rather than joined: a join would freeze
|
||||||
// the window for the length of the download.
|
// the window for the length of the download.
|
||||||
@@ -1222,6 +1278,50 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
Ok(())
|
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<library_ui::LibraryController>) -> 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 {
|
fn describe_camera(m: &Metadata) -> String {
|
||||||
match (&m.make, &m.model) {
|
match (&m.make, &m.model) {
|
||||||
(Some(make), Some(model)) => {
|
(Some(make), Some(model)) => {
|
||||||
|
|||||||
@@ -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<PinMessage> {
|
||||||
|
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<String> {
|
||||||
|
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.
|
/// Fetch one file in full, for opening it in develop.
|
||||||
///
|
///
|
||||||
/// Deliberately *not* the preview path. Browsing fetches a range and decodes
|
/// 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
|
/// 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.
|
/// 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
|
/// 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.
|
/// its own thread and the UI stays live, exactly as thumbnail fetching does.
|
||||||
pub fn spawn_full_fetch(
|
pub fn spawn_full_fetch(
|
||||||
creds: AppCredentials,
|
creds: AppCredentials,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
|
cache: Option<CacheContext>,
|
||||||
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
|
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
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()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
@@ -849,6 +1077,26 @@ pub fn spawn_full_fetch(
|
|||||||
|
|
||||||
let id = RemoteId::Path(RemotePath::new(&path));
|
let id = RemoteId::Path(RemotePath::new(&path));
|
||||||
let got = backend.get(&id, None).await.map_err(FetchFailure::from);
|
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);
|
let _ = tx.send(got);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+480
-22
@@ -12,6 +12,7 @@
|
|||||||
//! timers, which is the same shape [`crate::launch_ui`] uses for login.
|
//! timers, which is the same shape [`crate::launch_ui`] uses for login.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
@@ -83,7 +84,19 @@ pub struct LibraryController {
|
|||||||
window: RefCell<usize>,
|
window: RefCell<usize>,
|
||||||
/// Which rows already have a thumbnail fetch issued, so scrolling back
|
/// Which rows already have a thumbnail fetch issued, so scrolling back
|
||||||
/// does not refetch what is already on screen.
|
/// does not refetch what is already on screen.
|
||||||
requested: RefCell<std::collections::HashSet<usize>>,
|
/// 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<std::collections::HashSet<(i64, dr_thumbs::ThumbSize)>>,
|
||||||
scan_timer: RefCell<Option<slint::Timer>>,
|
scan_timer: RefCell<Option<slint::Timer>>,
|
||||||
thumb_timer: RefCell<Option<slint::Timer>>,
|
thumb_timer: RefCell<Option<slint::Timer>>,
|
||||||
/// The whole-library sweep, which outlives any one grid window.
|
/// 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
|
/// judgement, and carrying the old one over would report a server down
|
||||||
/// that was never contacted.
|
/// that was never contacted.
|
||||||
reachability: RefCell<dr_sync::Reachability>,
|
reachability: RefCell<dr_sync::Reachability>,
|
||||||
|
/// 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<Option<slint::Timer>>,
|
||||||
/// Narrow the grid to images whose original is stored locally.
|
/// Narrow the grid to images whose original is stored locally.
|
||||||
///
|
///
|
||||||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
/// 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
|
/// predicate over the cache, and folding two different joins into one type
|
||||||
/// would put the cache schema inside a rating concept.
|
/// would put the cache schema inside a rating concept.
|
||||||
local_only: std::cell::Cell<bool>,
|
local_only: std::cell::Cell<bool>,
|
||||||
|
/// 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<dr_catalog::Budget>,
|
||||||
|
/// 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<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryController {
|
impl LibraryController {
|
||||||
@@ -194,10 +228,56 @@ impl LibraryController {
|
|||||||
sidecar_timer: RefCell::new(None),
|
sidecar_timer: RefCell::new(None),
|
||||||
generation: std::cell::Cell::new(0),
|
generation: std::cell::Cell::new(0),
|
||||||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||||||
|
pin_timer: RefCell::new(None),
|
||||||
local_only: std::cell::Cell::new(false),
|
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<u64>) {
|
||||||
|
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
|
/// TRACES: FR-CAT-9
|
||||||
/// Whether the app currently believes the server is unreachable.
|
/// Whether the app currently believes the server is unreachable.
|
||||||
pub fn is_offline(&self) -> bool {
|
pub fn is_offline(&self) -> bool {
|
||||||
@@ -209,6 +289,83 @@ impl LibraryController {
|
|||||||
self.local_only.get()
|
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<dr_types::ImageId> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<dr_catalog::Cache> {
|
||||||
|
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<library::CacheContext> {
|
||||||
|
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.
|
/// Toggle the local-only filter, resetting the window.
|
||||||
///
|
///
|
||||||
/// The offset is cleared for the same reason a scope change clears it: the
|
/// 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);
|
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<LibraryController>) {
|
||||||
|
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<dr_types::ImageId> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT DISTINCT image_id FROM collection_members
|
||||||
|
WHERE collection_id IN ({placeholders})"
|
||||||
|
);
|
||||||
|
let params: Vec<rusqlite::types::Value> = 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<LibraryController>) {
|
||||||
|
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<LibraryController>) {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let params: Vec<rusqlite::types::Value> = 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
|
/// TRACES: FR-CAT-9
|
||||||
/// Paint the connectivity state into the window.
|
/// Paint the connectivity state into the window.
|
||||||
///
|
///
|
||||||
@@ -646,6 +1040,19 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
// query rather than another predicate threaded through the scoped one.
|
// query rather than another predicate threaded through the scoped one.
|
||||||
let trash = ctl.viewing_trash.get();
|
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 {
|
let total = if trash {
|
||||||
library::total_trashed(catalog).unwrap_or(0)
|
library::total_trashed(catalog).unwrap_or(0)
|
||||||
} else {
|
} else {
|
||||||
@@ -1097,18 +1504,26 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(i, _)| requested.insert(*i))
|
.filter_map(|(i, p)| {
|
||||||
.map(|(i, p)| library::ThumbnailRequest {
|
let image_id = *image_ids.get(i)?;
|
||||||
// Chosen from how large the cell is actually drawn, so a
|
// Chosen from how large the cell is actually drawn, so a
|
||||||
// zoomed grid asks for detail a 256px thumbnail cannot give
|
// zoomed grid asks for detail a 256px thumbnail cannot give
|
||||||
// and a wall of small cells does not pay for it.
|
// and a wall of small cells does not pay for it.
|
||||||
thumb_size: dr_thumbs::ThumbSize::for_cell(cell_pixels),
|
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,
|
row: i,
|
||||||
path: p.clone(),
|
path: p.clone(),
|
||||||
file_id: file_ids.get(i).copied().flatten(),
|
file_id: file_ids.get(i).copied().flatten(),
|
||||||
size: sizes.get(i).copied().unwrap_or(0),
|
size: sizes.get(i).copied().unwrap_or(0),
|
||||||
image_id: image_ids.get(i).copied().unwrap_or(0),
|
image_id,
|
||||||
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
|
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
|
||||||
|
thumb_size,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
@@ -1777,8 +2192,6 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
|||||||
// the viewport sits at row 0 shows an empty grid until the user scrolls.
|
// 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_to(position as i32);
|
||||||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
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);
|
load_window(window, ctl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1893,14 +2306,10 @@ where
|
|||||||
}
|
}
|
||||||
w.set_library_cell_size(next);
|
w.set_library_cell_size(next);
|
||||||
|
|
||||||
// Crossing the class boundary means the visible cells now want a
|
// No need to forget anything on a class change: the class is part
|
||||||
// resolution the store may not hold, and the window's capacity
|
// of the request key, so cells that now want the large resolution
|
||||||
// changed with the cell size. Both are answered by reloading.
|
// simply miss and ask for it, while the 256px ones they already
|
||||||
let was = dr_thumbs::ThumbSize::for_cell(current as u32);
|
// hold stay served.
|
||||||
let now = dr_thumbs::ThumbSize::for_cell(next as u32);
|
|
||||||
if was != now {
|
|
||||||
ctl.requested.borrow_mut().clear();
|
|
||||||
}
|
|
||||||
load_window(&w, &ctl);
|
load_window(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1942,9 +2351,6 @@ where
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*ctl.window.borrow_mut() = capacity;
|
*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);
|
load_window(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2028,9 +2434,6 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
*ctl.offset.borrow_mut() = desired;
|
*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);
|
load_window(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2173,7 +2576,6 @@ where
|
|||||||
// from the restored position has loaded rows above it.
|
// from the restored position has loaded rows above it.
|
||||||
let window_size = *ctl.window.borrow();
|
let window_size = *ctl.window.borrow();
|
||||||
*ctl.offset.borrow_mut() = resume.saturating_sub(window_size / 4);
|
*ctl.offset.borrow_mut() = resume.saturating_sub(window_size / 4);
|
||||||
ctl.requested.borrow_mut().clear();
|
|
||||||
load_window(&w, &ctl);
|
load_window(&w, &ctl);
|
||||||
|
|
||||||
// Before the grid is shown, not after: the markup gates it on
|
// 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
|
// TRACES: FR-CAT-9
|
||||||
// Retry now, rather than waiting out the backoff. A user who has just
|
// Retry now, rather than waiting out the backoff. A user who has just
|
||||||
// reconnected their wifi knows something the backoff does not.
|
// 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);
|
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]
|
#[test]
|
||||||
fn zoom_zero_is_the_whole_library() {
|
fn zoom_zero_is_the_whole_library() {
|
||||||
let full = (1_000, 2_000);
|
let full = (1_000, 2_000);
|
||||||
|
|||||||
@@ -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::<Settings>(&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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Settings>,
|
||||||
|
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<Option<String>>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsController {
|
||||||
|
pub fn new() -> Rc<Self> {
|
||||||
|
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<Item = &'a str>) -> slint::ModelRc<slint::SharedString> {
|
||||||
|
let v: Vec<slint::SharedString> = 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<T: PartialEq>(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<F>(window: &AppWindow, controller: Rc<SettingsController>, 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::<u32>() {
|
||||||
|
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<SettingsController> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
-3
@@ -4,6 +4,7 @@ import { LaunchScreen } from "launch.slint";
|
|||||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
||||||
|
import { SettingsPage } from "settings.slint";
|
||||||
|
|
||||||
export { LibraryCell, TimelineBar, CollectionRow }
|
export { LibraryCell, TimelineBar, CollectionRow }
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ component StatusBar inherits Rectangle {
|
|||||||
in property <bool> can-return-to-library: false;
|
in property <bool> can-return-to-library: false;
|
||||||
|
|
||||||
callback back-to-library();
|
callback back-to-library();
|
||||||
|
callback open-settings();
|
||||||
|
|
||||||
// Tall enough for a Button to sit in without the strip growing: the
|
// Tall enough for a Button to sit in without the strip growing: the
|
||||||
// control height and this strip are both 28px by design.
|
// control height and this strip are both 28px by design.
|
||||||
@@ -59,6 +61,14 @@ component StatusBar inherits Rectangle {
|
|||||||
|
|
||||||
Caption { text: root.layout-class; }
|
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
|
// Degraded performance, so this is a caution rather than an active
|
||||||
// state: the frame rate has fallen below what the compositing path is
|
// state: the frame rate has fallen below what the compositing path is
|
||||||
// supposed to sustain, which is exactly the assumption A1 exists to
|
// 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-toggle-local-only();
|
||||||
callback library-retry-connection();
|
callback library-retry-connection();
|
||||||
|
|
||||||
|
// --- pinning a collection offline (FR-NC-6a) ---
|
||||||
|
in property <bool> library-scope-pinned: false;
|
||||||
|
in property <int> library-pin-done: 0;
|
||||||
|
in property <int> library-pin-total: 0;
|
||||||
|
callback library-toggle-pin-scope();
|
||||||
|
|
||||||
in property <string> library-root-label: "";
|
in property <string> library-root-label: "";
|
||||||
in-out property <[TimelineBar]> library-timeline;
|
in-out property <[TimelineBar]> library-timeline;
|
||||||
in property <string> library-timeline-label: "";
|
in property <string> library-timeline-label: "";
|
||||||
@@ -403,6 +419,69 @@ export component AppWindow inherits Window {
|
|||||||
callback curve-reset(int);
|
callback curve-reset(int);
|
||||||
callback reset-all();
|
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 <bool> show-settings: false;
|
||||||
|
|
||||||
|
in property <string> settings-original-budget: "";
|
||||||
|
in property <bool> settings-original-unlimited: false;
|
||||||
|
in property <string> settings-thumbnail-budget: "";
|
||||||
|
in property <bool> settings-thumbnail-unlimited: false;
|
||||||
|
in property <bool> settings-keep-opened: true;
|
||||||
|
in property <string> 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 <int> settings-format-selected: 0;
|
||||||
|
in property <int> settings-quality: 90;
|
||||||
|
in property <bool> settings-quality-enabled: true;
|
||||||
|
in property <[string]> settings-colour-labels;
|
||||||
|
in property <int> settings-colour-selected: 0;
|
||||||
|
in property <[string]> settings-sizing-labels;
|
||||||
|
in property <int> settings-sizing-selected: 0;
|
||||||
|
in property <int> settings-sizing-value: 0;
|
||||||
|
in property <bool> settings-sizing-has-value: false;
|
||||||
|
in property <string> settings-sizing-unit: "px";
|
||||||
|
in property <bool> settings-allow-upscaling: false;
|
||||||
|
in property <[string]> settings-sharpening-labels;
|
||||||
|
in property <int> settings-sharpening-selected: 0;
|
||||||
|
in property <string> settings-filename-template: "";
|
||||||
|
in property <[string]> settings-collision-labels;
|
||||||
|
in property <int> settings-collision-selected: 0;
|
||||||
|
in property <bool> settings-strip-location: true;
|
||||||
|
in property <string> settings-destination: "";
|
||||||
|
in property <string> 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
|
// FR-UI-1: layout class follows window width, not device type. A narrow
|
||||||
// desktop window gets the compact layout, exactly as a tablet would.
|
// 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.
|
// One-way: report width outward, never read layout back into it.
|
||||||
changed width => { root.window-resized(self.width); }
|
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:
|
// The launch screen replaces the whole window rather than overlaying it:
|
||||||
// there is no library to look at until an account is configured.
|
// 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%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
signed-in: root.launch-signed-in;
|
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
|
// 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,
|
// grid are siblings here rather than the sidebar living inside the grid,
|
||||||
// because the drag that connects them has to be owned above both.
|
// 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;
|
background: Theme.ground;
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
@@ -518,6 +657,11 @@ export component AppWindow inherits Window {
|
|||||||
local-only: root.library-local-only;
|
local-only: root.library-local-only;
|
||||||
local-count: root.library-local-count;
|
local-count: root.library-local-count;
|
||||||
toggle-local-only() => { root.library-toggle-local-only(); }
|
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;
|
root-label: root.library-root-label;
|
||||||
timeline: root.library-timeline;
|
timeline: root.library-timeline;
|
||||||
timeline-label: root.library-timeline-label;
|
timeline-label: root.library-timeline-label;
|
||||||
@@ -554,6 +698,7 @@ export component AppWindow inherits Window {
|
|||||||
cell-clicked(i) => { root.library-cell-clicked(i); }
|
cell-clicked(i) => { root.library-cell-clicked(i); }
|
||||||
rescan() => { root.library-rescan(); }
|
rescan() => { root.library-rescan(); }
|
||||||
change-library() => { root.library-change(); }
|
change-library() => { root.library-change(); }
|
||||||
|
open-settings() => { root.settings-open(); }
|
||||||
|
|
||||||
cell-pressed(i, ctrl, shift) => {
|
cell-pressed(i, ctrl, shift) => {
|
||||||
root.library-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 {
|
StatusBar {
|
||||||
adapter: root.adapter;
|
adapter: root.adapter;
|
||||||
backend: root.backend;
|
backend: root.backend;
|
||||||
@@ -612,6 +757,7 @@ export component AppWindow inherits Window {
|
|||||||
// grid to return to.
|
// grid to return to.
|
||||||
can-return-to-library: root.library-total > 0;
|
can-return-to-library: root.library-total > 0;
|
||||||
back-to-library() => { root.back-to-library(); }
|
back-to-library() => { root.back-to-library(); }
|
||||||
|
open-settings() => { root.settings-open(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
|
|||||||
@@ -583,6 +583,8 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
callback rescan();
|
callback rescan();
|
||||||
/// Back to the launch screen, to change library or account.
|
/// Back to the launch screen, to change library or account.
|
||||||
callback change-library();
|
callback change-library();
|
||||||
|
/// Open the settings page.
|
||||||
|
callback open-settings();
|
||||||
|
|
||||||
// --- selection and drag ---
|
// --- selection and drag ---
|
||||||
//
|
//
|
||||||
@@ -646,6 +648,17 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
in property <string> offline-reason: "";
|
in property <string> offline-reason: "";
|
||||||
in property <string> offline-since: "";
|
in property <string> offline-since: "";
|
||||||
callback retry-connection();
|
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 <bool> scope-pinned: false;
|
||||||
|
in property <int> pin-done: 0;
|
||||||
|
in property <int> pin-total: 0;
|
||||||
|
callback toggle-pin-scope();
|
||||||
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
||||||
in property <bool> local-only: false;
|
in property <bool> local-only: false;
|
||||||
in property <int> local-count: 0;
|
in property <int> local-count: 0;
|
||||||
@@ -767,6 +780,19 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
clicked => { root.change-library(); }
|
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 {
|
Button {
|
||||||
// Shares the finished index so a second device inherits it
|
// Shares the finished index so a second device inherits it
|
||||||
// rather than repeating hours of range fetches.
|
// rather than repeating hours of range fetches.
|
||||||
@@ -782,6 +808,16 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
visible: !root.scanning;
|
visible: !root.scanning;
|
||||||
clicked => { root.rescan(); }
|
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;
|
: 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 ------------------------------------------------------
|
// --- offline ------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Above the scan error, and it suppresses it: when the server is
|
// Above the scan error, and it suppresses it: when the server is
|
||||||
|
|||||||
@@ -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 <string> label;
|
||||||
|
in property <bool> selected: false;
|
||||||
|
in property <bool> 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 <string> label;
|
||||||
|
in property <string> hint;
|
||||||
|
in property <[string]> options;
|
||||||
|
in property <int> selected: 0;
|
||||||
|
in property <bool> 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 <string> label;
|
||||||
|
in property <string> hint;
|
||||||
|
in-out property <bool> 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 <string> label;
|
||||||
|
in property <string> hint;
|
||||||
|
in-out property <string> text;
|
||||||
|
in property <string> unit;
|
||||||
|
in property <string> placeholder;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
in property <length> 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 <bool> 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 <string> original-budget;
|
||||||
|
in property <bool> original-unlimited: false;
|
||||||
|
in-out property <string> thumbnail-budget;
|
||||||
|
in property <bool> thumbnail-unlimited: false;
|
||||||
|
in property <bool> keep-opened: true;
|
||||||
|
/// What the cache currently holds. Empty hides the line.
|
||||||
|
in property <string> 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 <int> format-selected: 0;
|
||||||
|
in property <int> quality: 90;
|
||||||
|
in property <bool> quality-enabled: true;
|
||||||
|
in property <[string]> colour-labels;
|
||||||
|
in property <int> colour-selected: 0;
|
||||||
|
in property <[string]> sizing-labels;
|
||||||
|
in property <int> sizing-selected: 0;
|
||||||
|
in-out property <int> sizing-value: 0;
|
||||||
|
in property <bool> sizing-has-value: false;
|
||||||
|
in property <string> sizing-unit: "px";
|
||||||
|
in property <bool> allow-upscaling: false;
|
||||||
|
in property <[string]> sharpening-labels;
|
||||||
|
in property <int> sharpening-selected: 0;
|
||||||
|
in-out property <string> filename-template;
|
||||||
|
in property <[string]> collision-labels;
|
||||||
|
in property <int> collision-selected: 0;
|
||||||
|
in property <bool> strip-location: true;
|
||||||
|
in-out property <string> 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 <string> 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 <length> 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user