Files
DarkRoom/ui/dr-ui/src/settings_ui.rs
T
dtourolleandClaude Opus 5 03326242a1
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s
Make the CI checks say what they mean, and format the workspace
The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:02:51 +02:00

553 lines
21 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, 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);
}
}