Keep originals on this device, by pin and by use
Build and test / Desktop (Linux) (push) Failing after 1s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 2s

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:
2026-08-11 21:12:01 +02:00
co-authored by Claude Opus 5
parent cd75e5a4c6
commit fa12afed18
22 changed files with 4032 additions and 86 deletions
+3
View File
@@ -31,6 +31,9 @@ rusqlite.workspace = true
slint = { workspace = true, features = ["compat-1-2", "renderer-femtovg"] }
wgpu.workspace = true
anyhow.workspace = true
# `SettingsError` distinguishes an io failure from a malformed file, which the
# settings page reports differently; anyhow would flatten both to a string.
thiserror.workspace = true
log.workspace = true
pollster.workspace = true
# Runtime YAML only for `live-style`; release builds read the tokens the
+29 -6
View File
@@ -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
// would explain the failure. Catch it and forward the message instead.
let panic_tx = tx.clone();
// Logging is unreliable here: android_logger delivers lines emitted
// during startup but nothing from this thread, so a failure that never
// sends is otherwise completely opaque. Reporting the last step reached
// through the channel puts it on screen, which is the one channel known
// to work.
let step_tx = tx.clone();
let step = |s: &str| {
let _ = step_tx.send(LoginMessage::Progress(s.to_string()));
};
step("thread started");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
// `enable_all()` also enables the signal driver, which wants to own
// process-wide signal handling and is not something a worker thread
@@ -280,12 +290,13 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
return;
}
};
step("runtime built");
run_login_flow(rt, tx, server);
run_login_flow(rt, tx, server, &step);
}));
if let Err(panic) = result {
@@ -308,16 +319,19 @@ fn run_login_flow(
rt: tokio::runtime::Runtime,
tx: std::sync::mpsc::Sender<LoginMessage>,
server: String,
step: &dyn Fn(&str),
) {
{
rt.block_on(async {
step("building http client");
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
let _ = tx.send(LoginMessage::Failed(e.to_string()));
let _ = tx.send(LoginMessage::Failed(format!("http client: {e}")));
return;
}
};
step("requesting login flow");
log::info!("POST {server}/index.php/login/v2");
let flow = match auth::begin(&client, &server, "DarkRoom").await {
@@ -361,6 +375,8 @@ fn run_login_flow(
}
enum LoginMessage {
/// The last step the worker reached, for diagnosis when it dies silently.
Progress(String),
AwaitingApproval(String),
Success(Box<(dr_sync_nextcloud::AppCredentials, String)>),
Failed(String),
@@ -374,6 +390,9 @@ fn poll_channel(
) {
let timer = slint::Timer::default();
let ctl_for_cb = ctl.clone();
// Remembers the worker's last reported step, so a silent death names the
// point it got to rather than saying nothing.
let last_step = RefCell::new(String::from("nothing"));
timer.start(
slint::TimerMode::Repeated,
@@ -398,9 +417,10 @@ fn poll_channel(
// sending anything, which `catch_unwind` in
// spawn_login should now prevent — so say that the
// worker stopped rather than blaming the sign-in.
ctl.model
.borrow_mut()
.fail("the sign-in worker stopped without reporting why");
ctl.model.borrow_mut().fail(format!(
"the sign-in worker stopped after: {}",
last_step.borrow()
));
render(&w, ctl);
}
if let Some(t) = ctl.poll_timer.borrow().as_ref() {
@@ -412,6 +432,9 @@ fn poll_channel(
let mut done = false;
match msg {
LoginMessage::Progress(s) => {
*last_step.borrow_mut() = s;
}
LoginMessage::AwaitingApproval(url) => {
ctl.model.borrow_mut().await_approval(url);
}
+101 -1
View File
@@ -20,6 +20,8 @@ mod develop;
mod labels;
mod library;
mod library_ui;
mod settings_store;
mod settings_ui;
mod trash;
#[cfg(live_style)]
mod live_style;
@@ -540,6 +542,50 @@ pub fn run(paths: Vec<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
// app still browses through the preview path, just without develop.
let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
@@ -827,10 +873,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
return;
};
// TRACES: FR-NC-6a
// The cache is consulted first, so a second open of the same
// photograph is a disk read rather than a second download of tens
// of megabytes — and so a session's worth of images stays
// openable when the connection goes.
let cache = library.cache_context(&path);
if cache.is_none() {
log::debug!("no originals cache for {path}; fetching every time");
}
log::info!("fetching {path} for develop");
w.set_load_error("Downloading…".into());
let rx = library::spawn_full_fetch(creds, user_id, path.clone());
let rx = library::spawn_full_fetch(creds, user_id, path.clone(), cache);
// Polled on the UI thread rather than joined: a join would freeze
// the window for the length of the download.
@@ -1222,6 +1278,50 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
Ok(())
}
/// TRACES: FR-NC-6a
/// What the originals cache is holding, for the settings page.
///
/// Pinned and passive are reported separately because they answer different
/// questions: the passive figure is what the budget above it governs, while
/// the pinned figure is disk the user asked for and no ceiling will reclaim.
/// One combined number would make the budget look wrong whenever a large
/// collection was pinned.
fn describe_cache_usage(library: &Rc<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 {
match (&m.make, &m.model) {
(Some(make), Some(model)) => {
+248
View File
@@ -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.
///
/// Deliberately *not* the preview path. Browsing fetches a range and decodes
@@ -817,16 +1011,50 @@ impl std::fmt::Display for FetchFailure {
/// needs every photosite. On a RAW file that is tens of megabytes, which is
/// why this is a click-triggered download and not something the grid does.
///
/// # Read-through
///
/// With a `cache`, this checks disk before the network and stores what it
/// downloads. That is what makes opening the same photograph twice cost one
/// transfer, and what leaves a working session's images openable offline
/// without anyone having pinned anything.
///
/// A cache miss is not an error and a cache failure is not fatal: both fall
/// through to the network, which is exactly the behaviour that existed before
/// the cache did.
///
/// Returns the bytes on a channel rather than blocking: the download runs on
/// its own thread and the UI stays live, exactly as thumbnail fetching does.
pub fn spawn_full_fetch(
creds: AppCredentials,
user_id: String,
path: String,
cache: Option<CacheContext>,
) -> Receiver<Result<Vec<u8>, FetchFailure>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
// Opened on this thread: `rusqlite::Connection` is not `Send`, and the
// UI thread's handle cannot be borrowed across the spawn.
let cached = cache.as_ref().and_then(|c| {
let store = dr_catalog::Cache::open(&c.dir, c.budget).ok()?;
let conn = Catalog::open(&c.catalog_path).ok()?;
Some((store, conn))
});
if let (Some(c), Some((store, conn))) = (cache.as_ref(), cached.as_ref()) {
match store.load(conn.connection(), c.image, now_secs()) {
Ok(Some(bytes)) => {
log::info!("{path}: {} bytes from the local cache", bytes.len());
let _ = tx.send(Ok(bytes));
return;
}
Ok(None) => {}
// A cache that cannot be read is a cache miss, not a failure
// to open the photograph.
Err(e) => log::debug!("cache lookup for {path}: {e}"),
}
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -849,6 +1077,26 @@ pub fn spawn_full_fetch(
let id = RemoteId::Path(RemotePath::new(&path));
let got = backend.get(&id, None).await.map_err(FetchFailure::from);
// Store before sending, so the bytes are on disk by the time the
// image is on screen. Doing it after would leave a window where
// closing the app immediately lost the download.
if let (Ok(bytes), Some(c), Some((store, conn))) =
(&got, cache.as_ref().filter(|c| c.store), cached.as_ref())
{
// `pinned: false` — this is the passive population. A pin is
// something the user asks for explicitly; opening an image is
// not that, and treating it as one would make the pinned set
// grow silently and never be evicted.
if let Err(e) =
store.store(conn.connection(), c.image, &path, bytes, false, now_secs())
{
log::debug!("caching {path}: {e}");
} else if let Err(e) = store.enforce(conn.connection()) {
log::debug!("enforcing the cache budget: {e}");
}
}
let _ = tx.send(got);
});
});
+485 -27
View File
@@ -12,6 +12,7 @@
//! timers, which is the same shape [`crate::launch_ui`] uses for login.
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::mpsc::Receiver;
@@ -83,7 +84,19 @@ pub struct LibraryController {
window: RefCell<usize>,
/// Which rows already have a thumbnail fetch issued, so scrolling back
/// 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>>,
thumb_timer: RefCell<Option<slint::Timer>>,
/// The whole-library sweep, which outlives any one grid window.
@@ -157,6 +170,10 @@ pub struct LibraryController {
/// judgement, and carrying the old one over would report a server down
/// that was never contacted.
reachability: RefCell<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.
///
/// A `Cell` beside `filter` rather than a field inside it: the rating
@@ -164,6 +181,23 @@ pub struct LibraryController {
/// predicate over the cache, and folding two different joins into one type
/// would put the cache schema inside a rating concept.
local_only: std::cell::Cell<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 {
@@ -194,10 +228,56 @@ impl LibraryController {
sidecar_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
pin_timer: RefCell::new(None),
local_only: std::cell::Cell::new(false),
// The catalog's own floor until the settings page reports what the
// user has stored, which it does at startup before any fetch.
cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()),
keep_opened: std::cell::Cell::new(
dr_types::CacheSettings::default().keep_opened_originals,
),
})
}
/// TRACES: FR-NC-6a
/// Whether a develop open should keep the original it downloads.
///
/// Turning it off leaves what is already cached alone: those bytes are
/// paid for, and deleting them would make the switch destructive when it
/// only means "stop adding to this".
pub fn set_keep_opened_originals(&self, keep: bool) {
self.keep_opened.set(keep);
}
/// TRACES: FR-NC-6a
/// Set the ceiling on passively cached originals, and apply it now.
///
/// Applied immediately rather than only to later downloads: a user who has
/// just lowered the limit expects the space back, and a budget that took
/// effect only on the next fetch would leave the cache over its stated
/// ceiling for as long as they browsed nothing new.
pub fn set_cache_budget(&self, bytes: Option<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
/// Whether the app currently believes the server is unreachable.
pub fn is_offline(&self) -> bool {
@@ -209,6 +289,83 @@ impl LibraryController {
self.local_only.get()
}
/// TRACES: FR-NC-6a
/// The catalog id for a remote path currently in the grid.
///
/// The cache is keyed on `ImageId` because that is what survives a
/// server-side rename (FR-NC-5), while the develop callback carries only a
/// path. `paths` and `image_ids` are parallel to the model, so this is the
/// join between the two.
pub fn image_id_for_path(&self, path: &str) -> Option<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.
///
/// The offset is cleared for the same reason a scope change clears it: the
@@ -541,6 +698,243 @@ fn start_rescan(
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
}
/// TRACES: FR-NC-6a
/// Pin the scoped collection for offline use, or release it.
///
/// Pinning is two separate things, and keeping them separate is what makes the
/// button feel immediate: recording the *intent* is a local catalog write that
/// completes at once, and downloading the bytes is a background transfer that
/// may take a very long time. The button reflects the first.
fn toggle_pin_scope(window: &AppWindow, ctl: &Rc<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
/// 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.
let trash = ctl.viewing_trash.get();
// TRACES: FR-NC-6a
// Whether the newly scoped collection is already pinned. Read here rather
// than remembered, because a pin outlives the session that made it — on
// reopening the library the button has to show what the catalog says, not
// what this run happens to have done.
window.set_library_scope_pinned(match scope {
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
.map(|ids| collection_images(catalog, &ids))
.map(|images| scope_is_pinned(catalog, &images))
.unwrap_or(false),
None => false,
});
let total = if trash {
library::total_trashed(catalog).unwrap_or(0)
} else {
@@ -1097,18 +1504,26 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
paths
.iter()
.enumerate()
.filter(|(i, _)| requested.insert(*i))
.map(|(i, p)| library::ThumbnailRequest {
.filter_map(|(i, p)| {
let image_id = *image_ids.get(i)?;
// Chosen from how large the cell is actually drawn, so a
// zoomed grid asks for detail a 256px thumbnail cannot give
// and a wall of small cells does not pay for it.
thumb_size: dr_thumbs::ThumbSize::for_cell(cell_pixels),
row: i,
path: p.clone(),
file_id: file_ids.get(i).copied().flatten(),
size: sizes.get(i).copied().unwrap_or(0),
image_id: image_ids.get(i).copied().unwrap_or(0),
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
let thumb_size = dr_thumbs::ThumbSize::for_cell(cell_pixels);
// Keyed on the photograph, so scrolling back over a cell that
// has already been served does not ask for it again.
if !requested.insert((image_id, thumb_size)) {
return None;
}
Some(library::ThumbnailRequest {
row: i,
path: p.clone(),
file_id: file_ids.get(i).copied().flatten(),
size: sizes.get(i).copied().unwrap_or(0),
image_id,
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
thumb_size,
})
})
.collect()
};
@@ -1777,8 +2192,6 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
// the viewport sits at row 0 shows an empty grid until the user scrolls.
window.set_library_scroll_to(position as i32);
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
// A new window means new rows; nothing already fetched applies to them.
ctl.requested.borrow_mut().clear();
load_window(window, ctl);
}
@@ -1893,14 +2306,10 @@ where
}
w.set_library_cell_size(next);
// Crossing the class boundary means the visible cells now want a
// resolution the store may not hold, and the window's capacity
// changed with the cell size. Both are answered by reloading.
let was = dr_thumbs::ThumbSize::for_cell(current as u32);
let now = dr_thumbs::ThumbSize::for_cell(next as u32);
if was != now {
ctl.requested.borrow_mut().clear();
}
// No need to forget anything on a class change: the class is part
// of the request key, so cells that now want the large resolution
// simply miss and ask for it, while the 256px ones they already
// hold stay served.
load_window(&w, &ctl);
});
}
@@ -1942,9 +2351,6 @@ where
return;
}
*ctl.window.borrow_mut() = capacity;
// The window's extent changed, so rows outside the old one were
// never requested and rows inside it still hold their thumbnails.
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
});
}
@@ -2028,9 +2434,6 @@ where
}
*ctl.offset.borrow_mut() = desired;
// A different window means different rows; nothing already
// requested applies to them.
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
});
}
@@ -2173,7 +2576,6 @@ where
// from the restored position has loaded rows above it.
let window_size = *ctl.window.borrow();
*ctl.offset.borrow_mut() = resume.saturating_sub(window_size / 4);
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
// Before the grid is shown, not after: the markup gates it on
@@ -2324,6 +2726,17 @@ where
});
}
// TRACES: FR-NC-6a
// Pin or unpin the collection the grid is scoped to.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_pin_scope(move || {
let Some(w) = weak.upgrade() else { return };
toggle_pin_scope(&w, &ctl);
});
}
// TRACES: FR-CAT-9
// Retry now, rather than waiting out the backoff. A user who has just
// reconnected their wifi knows something the backoff does not.
@@ -2527,6 +2940,51 @@ mod tests {
assert_eq!(ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), ThumbSize::Grid);
}
#[test]
fn a_request_key_survives_the_window_moving() {
use dr_thumbs::ThumbSize;
use std::collections::HashSet;
// Keyed on the photograph, not its position. The grid is a window over
// the catalog, so row 7 is a different image after every scroll — a
// set of row indices had to be cleared on each move, and every visible
// cell then looked unrequested and was re-issued.
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
// A screenful at rows 0..3, holding images 100..103.
for id in 100..103 {
assert!(requested.insert((id, ThumbSize::Grid)), "first sight");
}
// Scrolled: the same photographs now occupy different rows.
for id in 100..103 {
assert!(
!requested.insert((id, ThumbSize::Grid)),
"image {id} must not be requested twice"
);
}
// A genuinely new photograph still is.
assert!(requested.insert((200, ThumbSize::Grid)));
}
#[test]
fn the_two_size_classes_are_requested_independently() {
use dr_thumbs::ThumbSize;
use std::collections::HashSet;
// Holding the 256px version says nothing about the large one, so
// zooming past the boundary must still ask.
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
assert!(requested.insert((1, ThumbSize::Grid)));
assert!(
requested.insert((1, ThumbSize::Large)),
"the large class is a separate request"
);
assert!(!requested.insert((1, ThumbSize::Grid)));
}
#[test]
fn zoom_zero_is_the_whole_library() {
let full = (1_000, 2_000);
+233
View File
@@ -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");
}
}
+562
View File
@@ -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
View File
@@ -4,6 +4,7 @@ import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
import { CollectionsPanel, CollectionRow } from "collections.slint";
import { SettingsPage } from "settings.slint";
export { LibraryCell, TimelineBar, CollectionRow }
@@ -23,6 +24,7 @@ component StatusBar inherits Rectangle {
in property <bool> can-return-to-library: false;
callback back-to-library();
callback open-settings();
// Tall enough for a Button to sit in without the strip growing: the
// control height and this strip are both 28px by design.
@@ -59,6 +61,14 @@ component StatusBar inherits Rectangle {
Caption { text: root.layout-class; }
// Reachable from develop as well as from the grid: export defaults are
// most likely to be wanted with a finished photograph on screen, which
// is exactly where this bar is and the library header is not.
Button {
text: "Settings";
clicked => { root.open-settings(); }
}
// Degraded performance, so this is a caution rather than an active
// state: the frame rate has fallen below what the compositing path is
// supposed to sustain, which is exactly the assumption A1 exists to
@@ -258,6 +268,12 @@ export component AppWindow inherits Window {
callback library-toggle-local-only();
callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) ---
in property <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-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
@@ -403,6 +419,69 @@ export component AppWindow inherits Window {
callback curve-reset(int);
callback reset-all();
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
// below: it is reachable from the library and from develop, so guarding it
// with `!show-library` or `!show-launch` would make which one you came from
// decide whether it appears.
//
// Every control saves on change (see `settings_ui.rs`), so there is no
// dirty state here and nothing to confirm on the way out.
in-out property <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
// desktop window gets the compact layout, exactly as a tablet would.
//
@@ -418,9 +497,69 @@ export component AppWindow inherits Window {
// One-way: report width outward, never read layout back into it.
changed width => { root.window-resized(self.width); }
// Settings, over everything. First in the file and first in z-order so the
// conditions below can be read as "and settings is not open".
if root.show-settings: SettingsPage {
width: 100%;
height: 100%;
original-budget: root.settings-original-budget;
original-unlimited: root.settings-original-unlimited;
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
original-unlimited-toggled(on) => {
root.settings-original-unlimited-toggled(on);
}
thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); }
thumbnail-unlimited-toggled(on) => {
root.settings-thumbnail-unlimited-toggled(on);
}
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
quality: root.settings-quality;
quality-enabled: root.settings-quality-enabled;
colour-labels: root.settings-colour-labels;
colour-selected: root.settings-colour-selected;
sizing-labels: root.settings-sizing-labels;
sizing-selected: root.settings-sizing-selected;
sizing-value: root.settings-sizing-value;
sizing-has-value: root.settings-sizing-has-value;
sizing-unit: root.settings-sizing-unit;
allow-upscaling: root.settings-allow-upscaling;
sharpening-labels: root.settings-sharpening-labels;
sharpening-selected: root.settings-sharpening-selected;
filename-template: root.settings-filename-template;
collision-labels: root.settings-collision-labels;
collision-selected: root.settings-collision-selected;
strip-location: root.settings-strip-location;
destination: root.settings-destination;
error: root.settings-error;
format-picked(i) => { root.settings-format-picked(i); }
quality-changed(q) => { root.settings-quality-changed(q); }
colour-picked(i) => { root.settings-colour-picked(i); }
sizing-picked(i) => { root.settings-sizing-picked(i); }
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
template-changed(t) => { root.settings-template-changed(t); }
collision-picked(i) => { root.settings-collision-picked(i); }
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
// The launch screen replaces the whole window rather than overlaying it:
// there is no library to look at until an account is configured.
if root.show-launch: LaunchScreen {
if !root.show-settings && root.show-launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
@@ -457,7 +596,7 @@ export component AppWindow inherits Window {
// has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both.
if !root.show-launch && root.show-library: Rectangle {
if !root.show-settings && !root.show-launch && root.show-library: Rectangle {
background: Theme.ground;
HorizontalLayout {
@@ -518,6 +657,11 @@ export component AppWindow inherits Window {
local-only: root.library-local-only;
local-count: root.library-local-count;
toggle-local-only() => { root.library-toggle-local-only(); }
scope-pinned: root.library-scope-pinned;
pin-done: root.library-pin-done;
pin-total: root.library-pin-total;
toggle-pin-scope() => { root.library-toggle-pin-scope(); }
root-label: root.library-root-label;
timeline: root.library-timeline;
timeline-label: root.library-timeline-label;
@@ -554,6 +698,7 @@ export component AppWindow inherits Window {
cell-clicked(i) => { root.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
change-library() => { root.library-change(); }
open-settings() => { root.settings-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
@@ -600,7 +745,7 @@ export component AppWindow inherits Window {
}
}
if !root.show-launch && !root.show-library: VerticalLayout {
if !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout {
StatusBar {
adapter: root.adapter;
backend: root.backend;
@@ -612,6 +757,7 @@ export component AppWindow inherits Window {
// grid to return to.
can-return-to-library: root.library-total > 0;
back-to-library() => { root.back-to-library(); }
open-settings() => { root.settings-open(); }
}
HorizontalLayout {
+68
View File
@@ -583,6 +583,8 @@ export component LibraryGrid inherits Rectangle {
callback rescan();
/// Back to the launch screen, to change library or account.
callback change-library();
/// Open the settings page.
callback open-settings();
// --- selection and drag ---
//
@@ -646,6 +648,17 @@ export component LibraryGrid inherits Rectangle {
in property <string> offline-reason: "";
in property <string> offline-since: "";
callback retry-connection();
// --- pinning (FR-NC-6a) -----------------------------------------------
//
// Whether the collection the grid is scoped to is kept offline, and how
// far the download has got. Progress is shown because pinning a trip is
// gigabytes of transfer — a button that appeared to do nothing for twenty
// minutes would read as broken.
in property <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.
in property <bool> local-only: false;
in property <int> local-count: 0;
@@ -767,6 +780,19 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.change-library(); }
}
// TRACES: FR-NC-6a
// Keep this collection offline. Only offered when the grid is
// scoped to one: "pin the whole library" is a different and
// much more expensive request, and a button that meant either
// depending on invisible state would be a trap.
if root.scope-label != "": Button {
text: root.scope-pinned ? "Pinned ✓" : "Pin offline";
active: root.scope-pinned;
enabled: !root.scanning;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-pin-scope(); }
}
Button {
// Shares the finished index so a second device inherits it
// rather than repeating hours of range fetches.
@@ -782,6 +808,16 @@ export component LibraryGrid inherits Rectangle {
visible: !root.scanning;
clicked => { root.rescan(); }
}
// Last in the row, and unconditional. The buttons before it
// come and go with what the grid is showing; settings is
// always reachable, and a control that moved as its
// neighbours appeared would be hunted for each time.
Button {
text: "Settings";
y: (parent.height - self.height) / 2;
clicked => { root.open-settings(); }
}
}
}
@@ -916,6 +952,38 @@ export component LibraryGrid inherits Rectangle {
: 0;
}
// --- pinning ------------------------------------------------------
//
// Its own line rather than the shared progress bar above: that one is
// driven by the scan and the sweep, and a pin runs alongside both.
if root.pin-total > 0: Rectangle {
height: 34px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Caption {
text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total;
vertical-alignment: center;
}
ProgressBar {
fraction: root.pin-done / max(1, root.pin-total);
y: (parent.height - self.height) / 2;
horizontal-stretch: 1;
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// --- offline ------------------------------------------------------
//
// Above the scan error, and it suppresses it: when the server is
+581
View File
@@ -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); }
}
}
}
}
}
}
}