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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user