`dr-sync` defines `RemoteBackend` and a capability model the engine adapts to, so a second backend can be added without touching the code that uses one. That boundary was documentation. Seven files in `dr-ui` constructed a `NextcloudBackend` directly, ten functions took one by concrete type, and exactly two call sites in the tree — both inside `dr-sync` itself — ever held the trait object. A WebDAV or local-folder backend would have had a well-written trait to implement and nowhere to go afterwards. The change is smaller than the finding suggests, because the trait was already right. Every method the UI has ever called on a backend — `get`, `put`, `list`, `delete`, `create_dir`, `move_to` — was already on it, so nothing had to be added and no behaviour moved. Ten signatures widened to `&dyn RemoteBackend`, sixteen constructions became `remote::connect`, and `remote.rs` is now the only file in the interface that names a connector. `connect` returns `Result<Box<dyn RemoteBackend>, RemoteError>`. The error type is `dr-sync`'s rather than the connector's, which is why every call site kept its shape — the `match`, the `let Ok(..) else`, and `.map_err(ScanFailure::local)?` all still read as they did. One wrinkle worth recording: `&Box<dyn Trait>` does not reach `&dyn Trait` on its own. The compiler reaches for unsizing, which wants `Box<dyn RemoteBackend>: RemoteBackend`, and reports a confusing missing impl rather than suggesting a deref. Twelve call sites therefore say `&*backend`, and two say `let backend: &dyn RemoteBackend = &*backend` where a borrow is shared across lanes. What this does *not* do is abstract credentials. `AppCredentials` is an app password from Login Flow v2 — a Nextcloud protocol, not a general notion of authenticating to a remote — and seven files still name it. An OAuth token, a bucket key pair and an app password have no useful common shape, so deciding what an account is across backends before a second one exists would be a confident guess. code-health.md CH-2 now records that as the remaining half, and it should wait for the backend that forces it. Verified: fmt clean, clippy clean at -D warnings, 2041 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
819 lines
32 KiB
Rust
819 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;
|
|
|
|
/// 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 over WebDAV.
|
|
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 session and
|
|
/// reports failures onto the launch screen's error line; this one is handed
|
|
/// credentials 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>,
|
|
creds: dr_sync_nextcloud::AppCredentials,
|
|
user_id: String,
|
|
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(&creds, &user_id) {
|
|
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);
|
|
}
|
|
}
|