Files
DarkRoom/ui/dr-ui/src/settings_ui.rs
T
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
2026-09-26 14:13:53 -04:00

915 lines
35 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::{
CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, FaceDetector,
GroupNavigation, LibrarySettings, OutputSharpening, ScreenSize, Settings, SizingMode,
};
use slint::ComponentHandle;
use crate::settings_store::SettingsStore;
use crate::{Adjustments, AppWindow, ExportOptions, Page};
/// 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.
///
/// `pub(crate)` rather than private because the settings page is no longer
/// the only surface that writes a preference: the scope chips are drawn
/// here *and* in the preset sheet, and the People screen carries the
/// grouping dials, for the reason identity.slint gives. All of them change
/// one stored set of per-device preferences saved to the same file, and all
/// of them come through here, so the sanitise-and-save discipline holds
/// wherever the control lives. The closure is what makes that safe, which
/// is why the shape stays.
pub(crate) 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);
// TRACES: FR-NC-6a | FR-UI-4
// Labelled from the record's own list, as the bar counts below are.
window.set_settings_fetch_ahead_labels(slint::ModelRc::new(slint::VecModel::from(
CacheSettings::AHEAD_CHOICES
.iter()
.map(|n| {
slint::SharedString::from(if *n == 0 {
"Off".to_string()
} else {
n.to_string()
})
})
.collect::<Vec<_>>(),
)));
window.set_settings_fetch_ahead_selected(index_of(
&CacheSettings::AHEAD_CHOICES,
&s.cache.fetch_ahead,
));
window.set_settings_write_xmp(s.library.write_xmp_sidecars);
// --- develop -------------------------------------------------------
//
// TRACES: FR-UI-1
// Where the adjustment groups are chosen from. The labels are written here
// rather than derived from the enum, because "Automatic" is a sentence
// about behaviour and the other two name controls the photographer can
// see; `GroupNavigation`'s variants are identifiers and would read as
// jargon in a picker.
window.set_settings_group_nav_labels(slint::ModelRc::new(slint::VecModel::from(vec![
slint::SharedString::from("Automatic"),
slint::SharedString::from("Tool rail"),
slint::SharedString::from("Tab strip"),
])));
window.set_settings_group_nav_selected(match s.develop.group_navigation {
GroupNavigation::Auto => 0,
GroupNavigation::Rail => 1,
GroupNavigation::Tabs => 2,
});
// Said plainly rather than left to be discovered by pressing it. The
// automatic answer depends on which build this is, which is exactly the
// sort of thing a photographer cannot see and should not have to infer.
window.set_settings_group_nav_auto_says(
if dr_plat::is_touch_first() {
"the tool rail"
} else {
"the tab strip"
}
.into(),
);
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into());
// --- faces ---------------------------------------------------------
//
// TRACES: FR-CULL-8
// Labelled from the enum's own order, so the chip index and
// `FaceDetector::ALL` cannot disagree about which detector was picked.
window.set_settings_face_detector_labels(slint::ModelRc::new(slint::VecModel::from(
FaceDetector::ALL
.iter()
.map(|d| slint::SharedString::from(d.label()))
.collect::<Vec<_>>(),
)));
window.set_settings_face_detector_selected(
FaceDetector::ALL
.iter()
.position(|d| *d == s.faces.detector)
.unwrap_or(0) as i32,
);
// --- 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
.global::<ExportOptions>()
.set_format_labels(labels(ExportFormat::ALL.iter().map(|f| f.label())));
window
.global::<ExportOptions>()
.set_format_selected(index_of(&ExportFormat::ALL, &s.export.format));
window
.global::<ExportOptions>()
.set_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
.global::<ExportOptions>()
.set_quality_enabled(s.export.format.is_lossy());
window
.global::<ExportOptions>()
.set_colour_labels(labels(ColourSpace::ALL.iter().map(|c| c.label())));
window
.global::<ExportOptions>()
.set_colour_selected(index_of(&ColourSpace::ALL, &s.export.colour_space));
window
.global::<ExportOptions>()
.set_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.global::<ExportOptions>().set_sizing_selected(
SizingMode::CHOICES
.iter()
.position(|m| m.same_mode(s.export.sizing))
.unwrap_or(0) as i32,
);
window
.global::<ExportOptions>()
.set_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
.global::<ExportOptions>()
.set_sizing_has_value(s.export.sizing.value().is_some());
// TRACES: FR-EXP-3
// The unit carries the axis for the box modes, so that two numeric fields
// one above the other are not two anonymous numbers. It reads "3840 px
// wide" and "2160 px high", which is the sentence the user is trying to
// write anyway.
window.global::<ExportOptions>().set_sizing_unit(
match s.export.sizing {
SizingMode::Percentage(_) => "%",
SizingMode::FitBox(..) | SizingMode::FillBox(..) => "px wide",
_ => "px",
}
.into(),
);
// TRACES: FR-EXP-3
// The box modes take two numbers, so the field beside the chips gets a
// partner and both stop being "the size value" — one is the width and one
// is the height, and a page that called either of them "Size value" would
// leave the user guessing which axis they were editing.
window.global::<ExportOptions>().set_sizing_value_label(
if s.export.sizing.height().is_some() {
"Width"
} else {
"Size value"
}
.into(),
);
window
.global::<ExportOptions>()
.set_sizing_height(s.export.sizing.height().unwrap_or(0) as i32);
window
.global::<ExportOptions>()
.set_sizing_has_height(s.export.sizing.height().is_some());
// TRACES: FR-EXP-3
// The panels, offered as buttons. Lit only on an exact match, and -1 —
// no chip lit — where the user has typed their own numbers: a preset that
// stayed selected while the fields said something else would be the page
// claiming the export is 4K when it is not.
window
.global::<ExportOptions>()
.set_screen_labels(labels(ScreenSize::ALL.iter().map(|p| p.label)));
window.global::<ExportOptions>().set_screen_selected(
ScreenSize::ALL
.iter()
.position(|p| {
s.export.sizing.value() == Some(p.width)
&& s.export.sizing.height() == Some(p.height)
})
.map_or(-1, |i| i as i32),
);
window
.global::<ExportOptions>()
.set_allow_upscaling(s.export.allow_upscaling);
window
.global::<ExportOptions>()
.set_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label())));
window
.global::<ExportOptions>()
.set_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening));
window
.global::<ExportOptions>()
.set_filename_template(s.export.filename_template.clone().into());
window
.global::<ExportOptions>()
.set_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label())));
window
.global::<ExportOptions>()
.set_collision_selected(index_of(&CollisionPolicy::ALL, &s.export.collision));
window
.global::<ExportOptions>()
.set_strip_location(s.export.strip_location);
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<dyn Fn(&Settings)> = 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);
wire_open_close(window, &controller, &on_open);
wire_cache(window, &controller, &on_budget_changed);
wire_faces(window, &controller, &on_budget_changed);
wire_export(window, &controller);
wire_reset(window, &controller, &on_budget_changed);
}
/// --- opening and closing -------------------------------------------
fn wire_open_close(
window: &AppWindow,
controller: &Rc<SettingsController>,
on_open: &Rc<dyn Fn(&AppWindow)>,
) {
{
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_active_page(Page::Settings);
});
}
// TRACES: FR-EXP-7 | FR-UI-5
// The export sheet shows the same values as the settings page, so it is
// read from disk and rendered the same way before it opens — without the
// page, and without `on_open`, which prepares the page's other panels.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_export_options_open(move || {
let Some(w) = weak.upgrade() else { return };
*ctl.settings.borrow_mut() = ctl.store.load();
render(&w, &ctl);
w.set_export_sheet_open(true);
});
}
{
let weak = window.as_weak();
window.on_settings_close(move || {
let Some(w) = weak.upgrade() else { return };
w.set_active_page(Page::None);
});
}
}
/// --- 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.
fn wire_cache(
window: &AppWindow,
controller: &Rc<SettingsController>,
on_budget_changed: &Rc<dyn Fn(&Settings)>,
) {
{
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);
});
}
{
// TRACES: FR-NC-6a | FR-UI-4
// Notified like a budget change: the depth is read by the library
// controller at the next open, not by this page.
let weak = window.as_weak();
let ctl = controller.clone();
let notify = on_budget_changed.clone();
window.on_settings_fetch_ahead_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(n) = CacheSettings::AHEAD_CHOICES.get(i as usize).copied() {
ctl.edit(|s| s.cache.fetch_ahead = n);
}
notify(&ctl.snapshot());
render(&w, &ctl);
});
}
{
// TRACES: FR-CAT-13 | NFR-R4
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_write_xmp_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.library.write_xmp_sidecars = on);
render(&w, &ctl);
});
}
}
/// --- faces ---------------------------------------------------------
///
/// TRACES: FR-CULL-8
/// Notified like a budget change, because the effect is outside this
/// page: the library controller carries the pipeline id into the next
/// sync, and the coverage line under the picker has to be re-counted
/// under the new id — which the caller's `on_budget_changed` does.
fn wire_faces(
window: &AppWindow,
controller: &Rc<SettingsController>,
on_budget_changed: &Rc<dyn Fn(&Settings)>,
) {
{
let weak = window.as_weak();
let ctl = controller.clone();
let notify = on_budget_changed.clone();
window.on_settings_face_detector_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
let Some(choice) = FaceDetector::ALL.get(i as usize).copied() else {
return;
};
ctl.edit(|s| s.faces.detector = choice);
notify(&ctl.snapshot());
render(&w, &ctl);
});
}
}
/// --- export --------------------------------------------------------
fn wire_export(window: &AppWindow, controller: &Rc<SettingsController>) {
{
let weak = window.as_weak();
let ctl = controller.clone();
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_group_nav_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
let choice = match i {
1 => GroupNavigation::Rail,
2 => GroupNavigation::Tabs,
_ => GroupNavigation::Auto,
};
ctl.edit(|s| s.develop.group_navigation = choice);
// Both, and in this order. `render` refreshes the picker;
// the second line is what actually moves the groups from one
// control to the other, and without it the preference would
// save correctly and change nothing until the next launch.
render(&w, &ctl);
w.global::<Adjustments>()
.set_groups_in_rail(choice.groups_in_rail(dr_plat::is_touch_first()));
});
}
window.global::<ExportOptions>().on_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
.global::<ExportOptions>()
.on_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.global::<ExportOptions>().on_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.global::<ExportOptions>().on_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| {
let old = s.export.sizing;
s.export.sizing = match (old.value(), old.height(), mode.height()) {
// Fit to fill and back: the same box, resolved
// differently. This is the switch people actually
// flip, and re-typing 3840 x 2160 to flip it would be
// absurd.
(Some(w), Some(h), Some(_)) => mode.with_box(w, h),
// Into a box from a single-number mode: the box opens
// at its own size. "2048" is a long edge, not a width
// and a height, and pairing it with whatever happened
// to be in the other field makes a box nobody asked
// for.
(_, None, Some(_)) => mode,
(Some(v), _, None) => mode.with_value(v),
(None, _, _) => mode,
};
});
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window
.global::<ExportOptions>()
.on_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
.global::<ExportOptions>()
.on_sizing_height_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_height(v))
}
_ => log::debug!("ignoring an unusable export height: {text:?}"),
}
render(&w, &ctl);
});
}
{
// TRACES: FR-EXP-3
// A panel's size, filled into both fields at once.
//
// It sets the numbers and nothing else — not the fit/fill choice. Both
// are legitimate against a screen: fill for a display that accepts one
// resolution and rejects everything else, fit for one that letterboxes
// politely, and guessing would silently discard the edges of a
// photograph for a user who wanted them.
let weak = window.as_weak();
let ctl = controller.clone();
window.global::<ExportOptions>().on_screen_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
if let Some(p) = ScreenSize::ALL.get(i.max(0) as usize) {
ctl.edit(|s| s.export.sizing = s.export.sizing.with_box(p.width, p.height));
}
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
window
.global::<ExportOptions>()
.on_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
.global::<ExportOptions>()
.on_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
.global::<ExportOptions>()
.on_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
.global::<ExportOptions>()
.on_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
.global::<ExportOptions>()
.on_strip_location_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.export.strip_location = on);
render(&w, &ctl);
});
}
// The export destination is an album, chosen and rendered by
// `albums_ui`, which has the catalog this page does not.
}
/// --- reset ---------------------------------------------------------
fn wire_reset(
window: &AppWindow,
controller: &Rc<SettingsController>,
on_budget_changed: &Rc<dyn Fn(&Settings)>,
) {
{
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 `ExportOptions::on_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);
}
}