Files
DarkRoom/ui/dr-ui/src/settings_ui.rs
T
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +02:00

820 lines
32 KiB
Rust

//! 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, ExportTarget, LibrarySettings, OutputSharpening,
Settings, SizingMode,
};
use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::AppWindow;
use dr_sync::Connection;
/// 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>,
/// TRACES: FR-EXP-6
/// The remote folder picker, while it is open.
///
/// The same [`FolderBrowser`](crate::launch::FolderBrowser) the launch
/// screen uses to choose a library root, reused rather than reimplemented:
/// it browses a remote tree and nothing about it is specific to what the
/// chosen folder is *for*. `None` means the picker is closed, which is
/// also the only state a device destination ever has — a path on this
/// machine is typed or chosen by the platform, not walked through a
/// backend.
pub browser: RefCell<Option<crate::launch::FolderBrowser>>,
/// Polls the folder listing while one is in flight.
///
/// Held here rather than in the function that starts it: a `slint::Timer`
/// stops the moment it is dropped, so a timer local to `spawn_folder_list`
/// would be collected before the listing it is waiting on ever arrived.
/// The same place `LaunchController` keeps its own poll timer.
poll_timer: RefCell<Option<slint::Timer>>,
}
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()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
})
}
/// The current settings, for whoever needs to act on them.
pub fn snapshot(&self) -> Settings {
self.settings.borrow().clone()
}
/// Adopt a folder chosen in the picker as the export destination.
///
/// Goes through `edit` like every other change, so it is saved the moment
/// it is chosen — the page has no Save button and a destination that
/// survived only until the window closed would be the one setting that
/// behaved differently from all the others.
pub fn set_destination(&self, path: String) {
self.edit(|s| s.export.set_active_destination(path));
}
/// Report a failure onto the page's error line.
pub fn set_error(&self, message: impl Into<String>) {
*self.error.borrow_mut() = Some(message.into());
}
/// 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_copy_includes_framing(s.develop.copy_includes_framing);
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into());
// --- library -------------------------------------------------------
//
// Labelled here rather than in the page, so the chips cannot offer a count
// the record does not: `BAR_CHOICES` is what `sanitise` snaps a
// hand-edited file to, and a third chip drawn beside them would be a
// choice that silently became one of the other two.
window.set_settings_timeline_bar_labels(slint::ModelRc::new(slint::VecModel::from(
LibrarySettings::BAR_CHOICES
.iter()
.map(|n| slint::SharedString::from(n.to_string()))
.collect::<Vec<_>>(),
)));
window.set_settings_timeline_bars_selected(index_of(
&LibrarySettings::BAR_CHOICES,
&s.library.timeline_bars,
));
// --- 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);
// `available()`, not `ALL`: Android cannot write to a device folder, and
// offering a target that fails at the last step is what made export look
// broken there rather than absent.
let targets = ExportTarget::available();
window.set_settings_target_labels(labels(targets.iter().map(|t| t.label())));
window.set_settings_target_selected(index_of(targets, &s.export.target));
window.set_settings_destination(s.export.active_destination().into());
// Derived from the target itself, never from its position in a list whose
// length differs by platform.
window.set_settings_browse_available(s.export.target.is_remote());
// The field means different things either side of the choice, and a
// placeholder saying which is cheaper than a paragraph under it.
// The placeholder names what an empty field *means*, which differs by
// target: on a filesystem it is a question, on the server it is the
// library root.
window.set_settings_destination_hint(s.export.destination_label().into());
// --- the remote folder picker --------------------------------------
{
let browser = controller.browser.borrow();
window.set_settings_browse_open(browser.is_some());
match browser.as_ref() {
Some(b) => {
// The root is shown as a word rather than as an empty string,
// which would read as a control that had lost its value.
window.set_settings_browse_path(
if b.path.is_empty() {
"Library root".to_string()
} else {
b.path.clone()
}
.into(),
);
window.set_settings_browse_loading(b.loading);
window.set_settings_browse_at_root(b.parent_path().is_none());
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
b.entries
.iter()
.map(|e| slint::SharedString::from(e.as_str()))
.collect::<Vec<_>>(),
)));
}
None => {
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
Vec::<slint::SharedString>::new(),
)));
window.set_settings_browse_loading(false);
}
}
}
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, G>(
window: &AppWindow,
controller: Rc<SettingsController>,
on_budget_changed: F,
on_open: G,
) where
F: Fn(&Settings) + 'static,
G: Fn(&AppWindow) + 'static,
{
let on_budget_changed = Rc::new(on_budget_changed);
// Run when the page opens, to fill in figures this module does not own —
// the face-indexing coverage, which lives in the catalog. Read here rather
// than kept up to date continuously, because it is only ever looked at
// when this page is on screen and it is two counts and an indexed scan.
let on_open: Rc<dyn Fn(&AppWindow)> = Rc::new(on_open);
// --- opening and closing -------------------------------------------
{
let weak = window.as_weak();
let on_open = on_open.clone();
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);
on_open(&w);
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_timeline_bars_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(n) = LibrarySettings::BAR_CHOICES.get(i as usize).copied() {
ctl.edit(|s| s.library.timeline_bars = n);
}
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);
});
}
// TRACES: FR-DEV-6
// Whether a copied edit carries the crop. Off by default — see
// `DevelopSettings::copy_includes_framing` for why that is the safe
// direction rather than merely the conservative one.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_copy_includes_framing_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.develop.copy_includes_framing = 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.set_active_destination(text.to_string()));
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_target_changed(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) {
// Nothing is cleared. Each target keeps its own destination
// (see `ExportSettings::remote_destination`), so switching to
// look at the other option no longer throws away the one
// already set — which is what made this setting appear not to
// stick.
ctl.edit(|s| s.export.target = t);
}
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);
});
}
}
/// TRACES: FR-EXP-6
/// List the folders under `path`, for the export destination picker.
///
/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared
/// with it. That one reaches into the `LaunchController` for its account and
/// reports failures onto the launch screen's error line; this one is handed a
/// connection and writes to the settings page. Factoring them together would
/// mean a function taking both controllers, or a trait implemented twice to
/// abstract two call sites — more machinery than the twenty lines it saves.
///
/// The *model* is shared, which is the part that matters: both drive a
/// [`FolderBrowser`](crate::launch::FolderBrowser), so navigation behaves
/// identically in both places.
pub fn spawn_folder_list(
weak: slint::Weak<AppWindow>,
ctl: Rc<SettingsController>,
conn: Connection,
path: String,
) {
use dr_sync::RemotePath;
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
std::thread::spawn(move || {
// Multi-thread, for the reason the login worker records: a
// current-thread runtime left reqwest's connection future unpolled on
// Android, and the await never resolved.
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_io()
.enable_time()
.build();
let Ok(rt) = rt else {
let _ = tx.send(Err("runtime".into()));
return;
};
rt.block_on(async {
match crate::remote::connect(&conn) {
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
Ok(entries) => {
let mut dirs: Vec<String> = entries
.iter()
.filter(|e| e.kind == dr_sync::EntryKind::Directory)
.map(|e| e.path.name().to_string())
.collect();
dirs.sort_by_key(|d| d.to_ascii_lowercase());
let _ = tx.send(Ok(dirs));
}
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
},
Err(e) => {
let _ = tx.send(Err(e.to_string()));
}
}
});
});
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(150),
move || {
let Some(w) = weak.upgrade() else { return };
match rx.try_recv() {
Ok(Ok(dirs)) => {
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.entries = dirs;
b.loading = false;
}
render(&w, &ctl_cb);
}
Ok(Err(e)) => {
// The picker stays open showing the folder it was on. A
// listing that failed is not a reason to discard where the
// user had navigated to.
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.loading = false;
}
*ctl_cb.error.borrow_mut() = Some(format!("Could not list folders: {e}"));
render(&w, &ctl_cb);
}
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() {
b.loading = false;
}
render(&w, &ctl_cb);
}
}
// The channel has delivered, so there is nothing left to poll
// for. Stopping it here rather than leaving it running is what
// keeps a page opened and closed twenty times from accumulating
// twenty timers.
if let Some(t) = ctl_cb.poll_timer.borrow().as_ref() {
t.stop();
}
},
);
*ctl.poll_timer.borrow_mut() = Some(timer);
}
#[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()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
})
}
#[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()),
browser: RefCell::new(None),
poll_timer: RefCell::new(None),
};
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);
}
}