rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused.
915 lines
35 KiB
Rust
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);
|
|
}
|
|
}
|