Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between images, and batch-apply to a selection. The last two have been here for a while; this is the first. The format is the sidecar's, deliberately. A preset *is* the non-default half of a version, so the lines are the same lines keyed the same way, which makes the two files diffable against each other and lets someone debugging an edit paste a block from one into the other. One file rather than one per preset: a preset per file makes the name a path, and every name then has to survive a filesystem — a `/` becomes a directory, a name differing only in case collides on one platform and not another, and renaming becomes two operations that can half-fail. As a key in a document it is none of those. Unknown *parameters* needed no machinery. `Preset` already holds whatever keys it is given and resolves them against the descriptors only at apply time, so one written by a newer build survives by being stored. Only lines that are not `op.param = float` at all are preserved verbatim, which is the sidecar's version-skew promise made here too. Applying is the paste path with a different source, so a preset reaches a selection through the sidecar read-modify-write that was already there: no graph, no decode, no GPU, forty files or one. Two smaller decisions worth the record. A library that fails to parse is held empty in memory and *not* written back over — settings regenerate themselves and this is work, so a parse failure must not be the moment it is destroyed. And every save persists immediately and rolls the in-memory copy back if the write fails, so the sheet never lists a preset the file does not have. The grid's "Presets" button is gated on the selection alone, unlike the "Paste to 40" beside it. That button needs a clipboard armed this session; the preset list is whatever was saved last month, and hiding it behind an unrelated action is what makes a feature only its author knows about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod masks_ui;
|
||||
mod net_runtime;
|
||||
mod preset_store;
|
||||
mod presets;
|
||||
mod remote;
|
||||
mod segmentation;
|
||||
@@ -2021,6 +2022,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
collections.clone(),
|
||||
);
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// The saved half of the same requirement, wired from the same bundle:
|
||||
// applying a named preset to the open image is the paste path with a
|
||||
// different source.
|
||||
presets::wire_named(
|
||||
&window,
|
||||
presets::NamedPresets::open(),
|
||||
presets::Develop {
|
||||
session: session.clone(),
|
||||
rows: rows.clone(),
|
||||
redraw: redraw.clone(),
|
||||
open: open_image.clone(),
|
||||
},
|
||||
settings.clone(),
|
||||
library.clone(),
|
||||
collections.clone(),
|
||||
);
|
||||
|
||||
// Close the knot left open beside `open_from_library`: the grid's
|
||||
// "‹ Library" button was wired before there was a session to save.
|
||||
let weak = window.as_weak();
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1
|
||||
//! Reads and writes the named preset library beside the other config.
|
||||
//!
|
||||
//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and
|
||||
//! separate from it for the reason that one is: two files, two lifetimes.
|
||||
//! Resetting preferences must not destroy a photographer's presets, and a
|
||||
//! preset library is the one file here that represents work rather than
|
||||
//! configuration — it is what someone would carry to another machine.
|
||||
//!
|
||||
//! # Why the library is loaded whole and saved whole
|
||||
//!
|
||||
//! There is no incremental path. The document is kilobytes (see
|
||||
//! [`PresetLibrary`]), the caller is holding the copy the user just edited,
|
||||
//! and a merge would let a preset the window has not drawn yet resurrect
|
||||
//! after a delete. The same argument the settings store makes about fields,
|
||||
//! made about entries.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_pipeline::PresetLibrary;
|
||||
|
||||
/// Loads and saves the named preset library.
|
||||
pub struct PresetStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl PresetStore {
|
||||
/// Open the store at the platform config location.
|
||||
///
|
||||
/// Linux: `$XDG_CONFIG_HOME/darkroom/presets.drpl`, falling back to
|
||||
/// `~/.config` — the same resolution `SettingsStore` does, so the files sit
|
||||
/// together and a user backing up one takes all of them.
|
||||
pub fn open() -> Self {
|
||||
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
|
||||
})
|
||||
.join("darkroom");
|
||||
Self::open_at(dir.join(format!(
|
||||
"presets.{}",
|
||||
dr_pipeline::preset::LIBRARY_EXTENSION
|
||||
)))
|
||||
}
|
||||
|
||||
/// Open at an explicit path — for tests, and for a non-default location.
|
||||
pub fn open_at(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The stored library, or an empty one.
|
||||
///
|
||||
/// A missing file is a first run. An unparseable one is answered with an
|
||||
/// empty library rather than an error, on the same reasoning the settings
|
||||
/// store gives — the alternative is an app that will not start until the
|
||||
/// user hand-edits a file.
|
||||
///
|
||||
/// The difference worth stating: settings are regenerated on the next
|
||||
/// save, where a preset library is *work*, and rewriting it whole would
|
||||
/// destroy whatever was in there. So a library that failed to parse is
|
||||
/// held empty in memory and **not** written back over until the user saves
|
||||
/// a preset, at which point they have chosen to. Nothing here deletes the
|
||||
/// file, and the warning names the path so it can be recovered by hand.
|
||||
pub fn load(&self) -> PresetLibrary {
|
||||
match std::fs::read_to_string(&self.path) {
|
||||
Ok(text) => match PresetLibrary::parse(&text) {
|
||||
Ok(library) => library,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"{} is not a readable preset library ({e}); \
|
||||
starting empty, the file is left alone",
|
||||
self.path.display()
|
||||
);
|
||||
PresetLibrary::default()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
|
||||
Err(e) => {
|
||||
log::warn!("reading {}: {e}; starting empty", self.path.display());
|
||||
PresetLibrary::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the library, replacing whatever was there.
|
||||
pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Write and rename, so an interrupted save cannot truncate the
|
||||
// existing file — the same discipline the settings and session stores
|
||||
// use, and it matters more here because what would be truncated is
|
||||
// every preset the user has ever made rather than a set of
|
||||
// preferences that rebuild themselves.
|
||||
let tmp = self.path.with_extension("tmp");
|
||||
std::fs::write(&tmp, library.to_text())?;
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PresetStoreError {
|
||||
#[error("preset library io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_pipeline::Preset;
|
||||
|
||||
/// The same hand-rolled temp directory the settings store's tests use —
|
||||
/// unique per process and thread, so a parallel run cannot collide.
|
||||
fn tempdir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dr-presets-test-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn store(name: &str) -> (PresetStore, PathBuf) {
|
||||
let dir = tempdir(name);
|
||||
(
|
||||
PresetStore::open_at(dir.join("nested").join("presets.drpl")),
|
||||
dir,
|
||||
)
|
||||
}
|
||||
|
||||
fn library() -> PresetLibrary {
|
||||
let mut lib = PresetLibrary::default();
|
||||
let mut params = std::collections::BTreeMap::new();
|
||||
params.insert(("exposure".to_string(), "exposure".to_string()), 0.75);
|
||||
lib.insert("Warm", Preset::from_params(params)).unwrap();
|
||||
lib.insert("Neutral", Preset::default()).unwrap();
|
||||
lib
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_round_trips_through_the_store() {
|
||||
let (store, _dir) = store("a-library-round-trips-through-the-store");
|
||||
store.save(&library()).unwrap();
|
||||
assert_eq!(store.load(), library());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_run_loads_an_empty_library() {
|
||||
let (store, _dir) = store("a-first-run-loads-an-empty-library");
|
||||
assert!(!store.path().exists());
|
||||
assert!(store.load().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_creates_the_config_directory() {
|
||||
let (store, _dir) = store("saving-creates-the-config-directory");
|
||||
store.save(&library()).unwrap();
|
||||
assert!(store.path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_left_alone_rather_than_overwritten() {
|
||||
// The difference from the settings store, and the reason this test
|
||||
// exists: what is on disk is work, so a parse failure must not be the
|
||||
// moment it is destroyed.
|
||||
let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten");
|
||||
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
||||
std::fs::write(store.path(), "this is not a preset library").unwrap();
|
||||
|
||||
assert!(store.load().is_empty());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(store.path()).unwrap(),
|
||||
"this is not a preset library"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_preset_survives_a_save_and_load() {
|
||||
// It is a real entry, not an absence — see `PresetLibrary::insert`.
|
||||
let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load");
|
||||
store.save(&library()).unwrap();
|
||||
assert_eq!(store.load().get("Neutral"), Some(&Preset::default()));
|
||||
}
|
||||
}
|
||||
+421
-6
@@ -35,10 +35,11 @@ use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_pipeline::{Preset, Scope, Sidecar};
|
||||
use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::develop::DevelopSession;
|
||||
use crate::preset_store::PresetStore;
|
||||
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
|
||||
|
||||
/// Where the develop view's current edit is stored.
|
||||
@@ -121,11 +122,21 @@ impl Clipboard {
|
||||
let Some(preset) = self.preset.borrow().clone() else {
|
||||
return String::new();
|
||||
};
|
||||
match preset.op_count(scope) {
|
||||
0 => "Neutral".to_string(),
|
||||
1 => "1 adjustment".to_string(),
|
||||
n => format!("{n} adjustments"),
|
||||
}
|
||||
describe(&preset, scope)
|
||||
}
|
||||
}
|
||||
|
||||
/// A short description of a preset's contents, for a label.
|
||||
///
|
||||
/// Free rather than a method on [`Clipboard`] because the named-preset sheet
|
||||
/// describes what *saving* would capture, and a second phrasing of the same
|
||||
/// count is a second thing to keep in step — the sheet saying "3 settings"
|
||||
/// beside a panel saying "3 adjustments" would read as two different numbers.
|
||||
pub fn describe(preset: &Preset, scope: Scope) -> String {
|
||||
match preset.op_count(scope) {
|
||||
0 => "Neutral".to_string(),
|
||||
1 => "1 adjustment".to_string(),
|
||||
n => format!("{n} adjustments"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +539,308 @@ pub fn wire(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named presets (FR-DEV-6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// The saved preset library, and the file it lives in.
|
||||
///
|
||||
/// # Why the library is held in memory as well as on disk
|
||||
///
|
||||
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
|
||||
/// copy is what the sheet is drawn from and what the next edit is applied to.
|
||||
/// Reading the file back after each change would be the same bytes and one
|
||||
/// more chance for a failed read to empty a list the user is looking at.
|
||||
///
|
||||
/// # A failed save is reported, not swallowed
|
||||
///
|
||||
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
|
||||
/// directory that is not writable. Losing a preset the user just named, with
|
||||
/// the sheet cheerfully listing it, would be discovered at the worst possible
|
||||
/// moment, so the write's result reaches the window.
|
||||
pub struct NamedPresets {
|
||||
store: PresetStore,
|
||||
library: RefCell<PresetLibrary>,
|
||||
}
|
||||
|
||||
impl NamedPresets {
|
||||
/// Load the library from its usual place.
|
||||
pub fn open() -> Rc<Self> {
|
||||
Self::at(PresetStore::open())
|
||||
}
|
||||
|
||||
/// Load from an explicit store — for tests, and for a non-default location.
|
||||
#[cfg(test)]
|
||||
pub fn open_at(path: PathBuf) -> Rc<Self> {
|
||||
Self::at(PresetStore::open_at(path))
|
||||
}
|
||||
|
||||
fn at(store: PresetStore) -> Rc<Self> {
|
||||
let library = RefCell::new(store.load());
|
||||
Rc::new(Self { store, library })
|
||||
}
|
||||
|
||||
/// The stored names, in the order they are written.
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
self.library.borrow().names().map(String::from).collect()
|
||||
}
|
||||
|
||||
/// The preset stored under `name`.
|
||||
pub fn get(&self, name: &str) -> Option<Preset> {
|
||||
self.library.borrow().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Store `preset` under `name` and persist.
|
||||
///
|
||||
/// The in-memory library is updated first and rolled back if the write
|
||||
/// fails, so what the sheet lists is always what is on disk. The
|
||||
/// alternative — writing first — would mean holding a preset the file does
|
||||
/// not have on every failure path.
|
||||
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
|
||||
let previous = {
|
||||
let mut library = self.library.borrow_mut();
|
||||
let existing = library.get(name.trim()).cloned();
|
||||
library.insert(name, preset).map_err(SaveError::Name)?;
|
||||
existing
|
||||
};
|
||||
self.persist(|library| match previous {
|
||||
Some(p) => {
|
||||
let _ = library.insert(name, p);
|
||||
}
|
||||
None => {
|
||||
library.remove(name.trim());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Save the library, undoing the in-memory change if the write fails.
|
||||
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
|
||||
let result = self.store.save(&self.library.borrow());
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
rollback(&mut self.library.borrow_mut());
|
||||
// Named, the way the settings page names its file: "could not
|
||||
// save" without saying where leaves the user nothing to check
|
||||
// and nothing to fix.
|
||||
Err(SaveError::Write(format!(
|
||||
"{}: {e}",
|
||||
self.store.path().display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a preset could not be saved.
|
||||
enum SaveError {
|
||||
/// The name itself was refused.
|
||||
Name(NameError),
|
||||
/// The library could not be written.
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl SaveError {
|
||||
/// What to put in front of the user.
|
||||
///
|
||||
/// The prose lives here rather than in `dr-pipeline`, which depends on
|
||||
/// nothing and has no business holding user-facing strings.
|
||||
fn message(&self) -> String {
|
||||
match self {
|
||||
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
|
||||
Self::Name(NameError::Unrepresentable) => {
|
||||
"A preset name cannot contain brackets or line breaks.".to_string()
|
||||
}
|
||||
Self::Write(e) => format!("Could not save presets: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the stored names onto the window.
|
||||
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
|
||||
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
|
||||
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
|
||||
}
|
||||
|
||||
/// Wire saving, applying, renaming and deleting named presets.
|
||||
///
|
||||
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
|
||||
/// same reason: applying a preset to the open image changes the graph, so it
|
||||
/// has to rebuild the panel, redraw the canvas and know where to save.
|
||||
pub fn wire_named(
|
||||
window: &AppWindow,
|
||||
named: Rc<NamedPresets>,
|
||||
develop: Develop,
|
||||
settings: Rc<settings_ui::SettingsController>,
|
||||
library: Rc<library_ui::LibraryController>,
|
||||
collections: Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
let Develop {
|
||||
session,
|
||||
rows,
|
||||
redraw,
|
||||
open,
|
||||
} = develop;
|
||||
|
||||
render_named(window, &named);
|
||||
|
||||
// --- save the open edit under a name ---------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let named = named.clone();
|
||||
let session = session.clone();
|
||||
window.on_save_preset(move |name| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
|
||||
w.set_preset_name_error("Open a photograph first.".into());
|
||||
return;
|
||||
};
|
||||
// Captured at full scope, exactly as a copy is: the scope is a
|
||||
// decision about applying, and a preset that had already discarded
|
||||
// the crop could never grow it back (see `Preset::capture`).
|
||||
match named.insert(&name, preset) {
|
||||
Ok(()) => {
|
||||
w.set_preset_name_error(Default::default());
|
||||
render_named(&w, &named);
|
||||
}
|
||||
Err(e) => w.set_preset_name_error(e.message().into()),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A refusal the user has started correcting is stale, and a message that
|
||||
// outlives its cause is one the user learns to ignore.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.on_preset_name_edited(move |_| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.set_preset_name_error(Default::default());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- what saving would capture ----------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
window.on_presets_opened(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// At the scope a save would use, which is full: a preset keeps
|
||||
// the framing it was captured with and drops it at apply time.
|
||||
let summary = session
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|s| describe(&s.copy_settings(), Scope::Everything))
|
||||
.unwrap_or_default();
|
||||
w.set_preset_capture_summary(summary.into());
|
||||
});
|
||||
}
|
||||
|
||||
// --- apply ------------------------------------------------------------
|
||||
//
|
||||
// One callback for both targets. Which one is meant is not a guess: the
|
||||
// sheet was opened from a view that set `preset-apply-count`, and the
|
||||
// label the user just read said "Applies to 12 selected photographs" or
|
||||
// said nothing. Deciding here from the same number keeps the promise.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let named = named.clone();
|
||||
let settings = settings.clone();
|
||||
let library = library.clone();
|
||||
let collections = collections.clone();
|
||||
let session = session.clone();
|
||||
let rows = rows.clone();
|
||||
let redraw = redraw.clone();
|
||||
let open = open.clone();
|
||||
window.on_apply_preset(move |name| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(preset) = named.get(&name) else {
|
||||
// Another window may have deleted it since this list was drawn.
|
||||
render_named(&w, &named);
|
||||
return;
|
||||
};
|
||||
let scope = scope_for(&settings.snapshot());
|
||||
|
||||
if w.get_preset_apply_count() > 0 {
|
||||
library_ui::paste_settings_to_selection(
|
||||
&w,
|
||||
&library,
|
||||
&collections.selected(),
|
||||
&preset,
|
||||
scope,
|
||||
);
|
||||
} else {
|
||||
{
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
s.apply_settings(&preset, scope);
|
||||
}
|
||||
// The same three steps a paste takes, for the same reasons:
|
||||
// many controls moved without any of them being touched, and
|
||||
// a deliberate discrete action is saved immediately.
|
||||
crate::sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
save_open_edit(&w, &open.borrow(), &session, &library);
|
||||
}
|
||||
|
||||
w.set_presets_open(false);
|
||||
});
|
||||
}
|
||||
|
||||
// --- rename -----------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let named = named.clone();
|
||||
window.on_rename_preset(move |from, to| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let renamed = {
|
||||
let mut library = named.library.borrow_mut();
|
||||
library.rename(&from, &to)
|
||||
};
|
||||
match renamed {
|
||||
Ok(_) => {
|
||||
// Nothing to roll back to on a failed write beyond the
|
||||
// name it had, which is what this restores.
|
||||
let from = from.to_string();
|
||||
let to = to.to_string();
|
||||
if let Err(e) = named.persist(move |library| {
|
||||
let _ = library.rename(&to, &from);
|
||||
}) {
|
||||
w.set_preset_name_error(e.message().into());
|
||||
}
|
||||
}
|
||||
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
|
||||
}
|
||||
render_named(&w, &named);
|
||||
});
|
||||
}
|
||||
|
||||
// --- delete -----------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let named = named.clone();
|
||||
window.on_delete_preset(move |name| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let removed = {
|
||||
let mut library = named.library.borrow_mut();
|
||||
let previous = library.get(&name).cloned();
|
||||
library.remove(&name);
|
||||
previous
|
||||
};
|
||||
if let Some(previous) = removed {
|
||||
let name = name.to_string();
|
||||
if let Err(e) = named.persist(move |library| {
|
||||
let _ = library.insert(&name, previous);
|
||||
}) {
|
||||
w.set_preset_name_error(e.message().into());
|
||||
}
|
||||
}
|
||||
render_named(&w, &named);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the epoch, or zero if the clock is before it.
|
||||
///
|
||||
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
|
||||
@@ -804,4 +1117,106 @@ mod tests {
|
||||
assert!(clipboard.is_armed());
|
||||
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Named presets
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
|
||||
let dir = tempdir(name);
|
||||
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_preset_is_on_disk_before_the_call_returns() {
|
||||
// Not on the way out, and not on a timer: a preset the user named and
|
||||
// then lost to a crash is the one failure this feature cannot have.
|
||||
let (presets, dir) = named("saved-immediately");
|
||||
presets
|
||||
.insert("Warm", Preset::capture(&edited()))
|
||||
.ok()
|
||||
.expect("saved");
|
||||
|
||||
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
|
||||
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_preset_carries_the_edit_it_captured() {
|
||||
let (presets, _dir) = named("carries-the-edit");
|
||||
presets.insert("Warm", Preset::capture(&edited())).ok();
|
||||
|
||||
let preset = presets.get("Warm").expect("stored");
|
||||
let mut target = EditGraph::default_chain();
|
||||
preset.apply(&mut target, Scope::Adjustments);
|
||||
assert_eq!(
|
||||
target.param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE
|
||||
),
|
||||
Some(1.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
|
||||
let (presets, _dir) = named("refused-name");
|
||||
assert!(presets.insert("", Preset::default()).is_err());
|
||||
assert!(presets.insert("bracket]", Preset::default()).is_err());
|
||||
assert!(presets.names().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
|
||||
// The rollback. A sheet listing a preset the file does not have is a
|
||||
// loss the user discovers later, at the moment they reach for it.
|
||||
let dir = tempdir("failed-write");
|
||||
// A *file* where the store wants a directory, so `create_dir_all`
|
||||
// fails and the save cannot succeed.
|
||||
let blocked = dir.join("blocked");
|
||||
std::fs::write(&blocked, b"not a directory").unwrap();
|
||||
|
||||
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
|
||||
assert!(presets.insert("Warm", Preset::default()).is_err());
|
||||
assert!(
|
||||
presets.names().is_empty(),
|
||||
"the failed save left a preset behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_overwrite_puts_the_original_back() {
|
||||
// The other half of the rollback, and the one that loses work if it is
|
||||
// wrong: overwriting is destructive, so a failed overwrite has to
|
||||
// restore what was there rather than leave the name holding the new
|
||||
// value the file never received.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempdir("failed-overwrite");
|
||||
let path = dir.join("presets.drpl");
|
||||
let presets = NamedPresets::open_at(path.clone());
|
||||
presets.insert("Warm", Preset::capture(&edited())).ok();
|
||||
let original = presets.get("Warm").expect("stored");
|
||||
|
||||
// The directory exists, so `create_dir_all` still succeeds and it is
|
||||
// the write of the temporary file that fails — which is the path a
|
||||
// full disk takes.
|
||||
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
|
||||
perms.set_mode(0o500);
|
||||
std::fs::set_permissions(&dir, perms.clone()).unwrap();
|
||||
|
||||
let failed = presets.insert("Warm", Preset::default()).is_err();
|
||||
|
||||
// Restore the permissions before asserting, so a failure here does not
|
||||
// leave an undeletable directory behind for the next run.
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(&dir, perms).unwrap();
|
||||
|
||||
assert!(failed, "the write should have failed");
|
||||
assert_eq!(
|
||||
presets.get("Warm"),
|
||||
Some(original),
|
||||
"the failed overwrite kept the new value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user