Files
DarkRoom/core/dr-pipeline/src/preset.rs
T
dtourolleandClaude Opus 5 5a8327824f 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>
2026-08-29 20:07:14 +02:00

1083 lines
42 KiB
Rust

//! TRACES: FR-DEV-6
//! An edit lifted off one photograph and dropped onto another.
//!
//! # This is the same data a sidecar already stores
//!
//! A [`Preset`] is a map of `(op, param) -> value` holding only what differs
//! from default — which is precisely [`Version::params`](crate::Version). That
//! is not a coincidence to be tidied away later: copying settings between two
//! images and persisting one image's settings are the same operation seen from
//! two ends, so they share one representation and one capture routine
//! ([`Preset::capture`], which `sidecar` calls). A second, parallel notion of
//! "a bundle of parameter values" would be a second thing to keep in step with
//! the descriptors.
//!
//! It follows that this module names no operation either, with the single
//! exception of framing — for the reason below, which is a statement about
//! photographs rather than about code.
//!
//! # Why framing is its own scope
//!
//! A crop is a decision about *this* photograph's composition. Copying colour
//! from one frame to the next is what a photographer means by "make these
//! match"; copying the crop as well re-frames every one of them to a rectangle
//! chosen while looking at a different picture, and on a batch of forty that is
//! forty compositions destroyed by one action.
//!
//! So the default [`Scope::Adjustments`] leaves the target's framing where it
//! is, and [`Scope::Everything`] is available for the case the exclusion exists
//! to protect against being impossible otherwise — applying one aspect ratio
//! across a shoot. The choice is the caller's; neither is hardcoded here.
//!
//! # Why applying replaces rather than overlays
//!
//! Within its scope, [`Preset::apply`] resets first. A parameter absent from
//! the preset means *default*, exactly as absence means default in a sidecar
//! — so pasting a neutral edit clears the target rather than leaving the
//! target's own exposure standing underneath. Overlaying would make the result
//! depend on what the target happened to hold, and "these two images now match"
//! is the whole claim the action makes.
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::descriptor::{OpId, ParamId};
use crate::graph::EditGraph;
/// Which part of an edit a copy carries.
///
/// Two variants rather than a per-operation mask because the distinction being
/// drawn is not "which operations" but "is this about the picture's colour or
/// about its shape". Everything in the chain but framing answers the first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Scope {
/// Colour and tone. The target keeps its own crop, straightening,
/// rotation and flips.
#[default]
Adjustments,
/// The whole edit, framing included.
Everything,
}
impl Scope {
/// Whether this scope reaches the named operation.
///
/// Takes a `&str` rather than an [`OpId`] because the caller may be
/// holding a name read from a file, which has no `'static` lifetime to
/// offer — the sidecar path amends a parameter map without ever building
/// a graph.
pub fn covers(self, op: &str) -> bool {
match self {
Self::Everything => true,
Self::Adjustments => op != crate::framing::ID.0,
}
}
}
/// A set of non-default parameter values, ready to apply elsewhere.
///
/// Ordered, so two captures of the same edit compare equal and a caller can
/// tell whether the clipboard actually changed.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Preset {
params: BTreeMap<(String, String), f32>,
}
impl Preset {
/// Every non-default parameter in `graph`.
///
/// Walks [`EditGraph::capabilities`] — the same list the panel builds
/// controls from and the sidecar persists — so an operation is copyable by
/// virtue of being in the chain, with nothing to register (FR-DEV-3c).
///
/// Captured at full [`Scope::Everything`]: filtering happens when the
/// preset is *applied*, not when it is taken. Otherwise the clipboard would
/// have to be re-copied to change one's mind about framing, and a preset
/// that had already discarded the crop could never grow it back.
pub fn capture(graph: &EditGraph) -> Self {
let mut params = BTreeMap::new();
for cap in graph.capabilities() {
for p in &cap.params {
if p.is_modified() {
params.insert((cap.id.0.to_string(), p.id.0.to_string()), p.value);
}
}
}
Self { params }
}
/// Build from an already-captured parameter map — a sidecar's, typically.
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
Self { params }
}
/// The parameters, for a caller that stores them.
pub fn params(&self) -> &BTreeMap<(String, String), f32> {
&self.params
}
/// Consume into the parameter map.
pub fn into_params(self) -> BTreeMap<(String, String), f32> {
self.params
}
/// Whether this preset carries anything at all.
///
/// An empty preset is a *neutral* edit rather than a missing one, and
/// applying it is meaningful: it returns the target to default. What this
/// answers is whether there is a clipboard to offer, which is why the UI
/// asks it before enabling a paste.
pub fn is_empty(&self) -> bool {
self.params.is_empty()
}
/// How many parameters were captured.
pub fn len(&self) -> usize {
self.params.len()
}
/// Whether this preset carries any framing — a crop, a rotation, a flip or
/// a straightening angle.
///
/// What the interface asks to decide whether offering "include crop and
/// rotation" would change anything for *this* clipboard. Offering it on a
/// copy that has no framing in it promises an effect that cannot happen.
pub fn touches_framing(&self) -> bool {
self.params
.keys()
.any(|(op, _)| !Scope::Adjustments.covers(op))
}
/// How many operations this preset touches, in scope.
///
/// For the interface's "3 adjustments" readout. Counted over operations
/// rather than parameters because thirty-six mixer sliders is a number
/// about the mixer's shape, not about how much was copied.
pub fn op_count(&self, scope: Scope) -> usize {
let mut ops: Vec<&str> = self
.params
.keys()
.map(|(op, _)| op.as_str())
.filter(|op| scope.covers(op))
.collect();
ops.sort_unstable();
ops.dedup();
ops.len()
}
/// Apply to a graph, replacing whatever it held within `scope`.
///
/// Parameters this build does not recognise are skipped with a warning by
/// the same route a sidecar's are — a preset may have been captured by a
/// newer build, and an unknown name must cost its own line rather than the
/// paste.
///
/// The **view is preserved**. Zoom and pan say where the user is looking,
/// not what the photograph is; resetting the framing would throw them back
/// to a fitted view mid-comparison, which reads as the paste having
/// navigated somewhere. This mirrors `DevelopSession::reset_framing`, and
/// lives here so every caller inherits it rather than each remembering.
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) {
let view = graph.framing().view();
// Clear the scope first, so absence means default (see the module
// note). Collected before writing because `capabilities` borrows the
// graph and `set_param` needs it mutably.
let clears: Vec<(OpId, ParamId, f32)> = graph
.capabilities()
.iter()
.filter(|cap| scope.covers(cap.id.0))
.flat_map(|cap| cap.params.iter().map(|p| (cap.id, p.id, p.default)))
.collect();
for (op, param, default) in clears {
graph.set_param(op, param, default);
}
for ((op, param), value) in &self.params {
if !scope.covers(op) {
continue;
}
let Some((op, param)) = resolve(graph, op, param) else {
log::warn!("preset: unknown parameter {op}.{param}; ignoring");
continue;
};
graph.set_param(op, param, *value);
}
graph.framing_mut().set_view(view);
}
/// Apply to a parameter map — the sidecar of an image that is not open.
///
/// The batch path. Applying to forty images by loading forty edit graphs
/// would mean instantiating the whole chain forty times to move some
/// numbers between two maps; the graph adds nothing here because there is
/// no rendering to do and clamping happens when the file is next read into
/// one ([`EditGraph::set_param`] clamps, and `Version::apply` goes through
/// it).
///
/// Same replacement rule as [`Self::apply`]: the target's in-scope keys go,
/// the preset's arrive, and out-of-scope keys — the target's own crop, on
/// the default scope — are left exactly as they were.
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
target.retain(|(op, _), _| !scope.covers(op));
for ((op, param), value) in &self.params {
if scope.covers(op) {
target.insert((op.clone(), param.clone()), *value);
}
}
}
}
/// Find the `'static` ids matching these names, or `None` if this build has no
/// such parameter.
///
/// Looking them up in the descriptors rather than leaking the caller's strings
/// is what bounds memory: a name read from a file or carried on a clipboard
/// never becomes a `'static`.
pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, ParamId)> {
let cap = graph.capabilities().into_iter().find(|c| c.id.0 == op)?;
let p = cap.params.iter().find(|p| p.id.0 == param)?;
Some((cap.id, p.id))
}
// ---------------------------------------------------------------------------
// Named presets
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// Format version of a preset library file.
///
/// Present where `settings.json` has no version field, and the difference is
/// not an inconsistency. Settings are a flat bag of `#[serde(default)]`
/// fields, so an older file is *missing* keys rather than wrong about them and
/// additive change needs no version. This file has structure — blocks, and a
/// name carried in a block header — and a change to what a block *means* is
/// not something a reader can detect by noticing an absent key.
pub const LIBRARY_FORMAT_VERSION: u32 = 1;
/// The file extension for a DarkRoom preset library.
pub const LIBRARY_EXTENSION: &str = "drpl";
/// Why a name was refused.
///
/// A closed set rather than a string, so the interface can say something
/// specific about each and the message is not written here — this crate
/// depends on nothing and has no business holding user-facing prose.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameError {
/// Empty, or nothing but whitespace.
Empty,
/// Contains a character the block header cannot carry: `[`, `]`, or a
/// line break.
///
/// A round-trip constraint rather than a matter of taste. The header is
/// `[preset <name>]`, so a `]` inside the name would make the file parse
/// back as a *different* library, and a newline would make it parse back
/// as two.
Unrepresentable,
}
/// TRACES: FR-DEV-6
/// A set of named presets, as stored.
///
/// # Why one file rather than one file 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 that differs only
/// in case collides on one platform and not another, and renaming becomes two
/// operations that can half-fail. Here the name is a key in a document, so
/// renaming is a map operation, deleting cannot leave an orphan, and the whole
/// library is written atomically by the same tmp-and-rename the settings store
/// uses.
///
/// The cost is that the file is rewritten whole on every change. A preset is a
/// few dozen floats and a photographer has tens of them, not thousands, so the
/// file is kilobytes; the trade would look different at a scale this is not.
///
/// # Why the sidecar's shape rather than JSON
///
/// A preset *is* the non-default half of a version (see the module note), so
/// the lines here are the lines a sidecar carries, keyed the same way. That
/// makes the two files diffable against each other and lets someone debugging
/// an edit paste a block from one into the other. It also keeps this crate
/// dependency-free, which is the property that lets it be tested without a
/// device (ARCH §6.5a).
///
/// # Ordering
///
/// By name, so the same library always writes the same bytes and a caller may
/// compare content to decide whether a write is needed — the same
/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the
/// same reason.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PresetLibrary {
presets: BTreeMap<String, Preset>,
/// Lines inside a `[preset]` block that were not `op.param = float`.
///
/// Keyed by preset name and written back verbatim, so a build that
/// predates whatever wrote them round-trips the file without discarding
/// it. Unknown *parameters* need no such machinery: [`Preset`] holds
/// whatever keys it was given and resolves them against the descriptors
/// only at apply time, so a parameter this build has never heard of
/// survives simply by being stored.
unknown: BTreeMap<String, Vec<String>>,
}
impl PresetLibrary {
/// Whether a name is storable, and why not if it is not.
///
/// Leading and trailing whitespace is trimmed rather than refused — it is
/// almost always a stray keystroke, and refusing it would mean a dialogue
/// about a space.
pub fn check_name(name: &str) -> Result<String, NameError> {
let name = name.trim();
if name.is_empty() {
return Err(NameError::Empty);
}
if name.contains([']', '[', '\n', '\r']) {
return Err(NameError::Unrepresentable);
}
Ok(name.to_string())
}
/// Store `preset` under `name`, replacing any preset already there.
///
/// Returns whether something was replaced.
///
/// Replacing rather than refusing, with [`Self::contains`] beside it for a
/// caller that wants to ask first: whether overwriting needs a
/// confirmation is a question about the interface, and answering it here
/// would force every caller into the same answer.
///
/// An *empty* preset is stored like any other. A neutral edit is a real
/// thing to save — applying it returns an image to default, which is the
/// fastest "undo everything on these forty frames" there is — and the
/// module note above is the same argument made about the clipboard.
pub fn insert(&mut self, name: &str, preset: Preset) -> Result<bool, NameError> {
let name = Self::check_name(name)?;
let replaced = self.presets.insert(name, preset).is_some();
Ok(replaced)
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<&Preset> {
self.presets.get(name)
}
/// Whether a preset is stored under `name`.
pub fn contains(&self, name: &str) -> bool {
self.presets.contains_key(name)
}
/// Remove the preset stored under `name`, reporting whether there was one.
pub fn remove(&mut self, name: &str) -> bool {
self.unknown.remove(name);
self.presets.remove(name).is_some()
}
/// Rename `from` to `to`.
///
/// `Ok(false)` means there was nothing called `from` — not an error, since
/// the caller may be acting on a list another window has already changed.
/// Renaming onto an existing name replaces it, for the same reason
/// [`Self::insert`] does.
pub fn rename(&mut self, from: &str, to: &str) -> Result<bool, NameError> {
let to = Self::check_name(to)?;
let Some(preset) = self.presets.remove(from) else {
return Ok(false);
};
if let Some(unknown) = self.unknown.remove(from) {
self.unknown.insert(to.clone(), unknown);
}
self.presets.insert(to, preset);
Ok(true)
}
/// Every stored name, in the order they are written.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.presets.keys().map(String::as_str)
}
/// Every stored preset with its name, in the order they are written.
pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
self.presets.iter().map(|(n, p)| (n.as_str(), p))
}
/// How many presets are stored.
pub fn len(&self) -> usize {
self.presets.len()
}
/// Whether nothing is stored.
pub fn is_empty(&self) -> bool {
self.presets.is_empty()
}
/// Serialise to the on-disk form.
///
/// Deterministic, like the sidecar's: the same library always produces the
/// same bytes.
pub fn to_text(&self) -> String {
let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n");
for (name, preset) in &self.presets {
let _ = write!(out, "\n[preset {name}]\n");
for ((op, param), value) in preset.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
}
for line in self.unknown.get(name).into_iter().flatten() {
let _ = writeln!(out, "{line}");
}
}
out
}
/// Parse the on-disk form.
///
/// Tolerant on the same terms as the sidecar's parser, and for a weaker
/// version of the same reason: a preset library is not the authoritative
/// store an edit lives in, but it is still work the user did by hand, and
/// one bad line must cost that line rather than the collection. The only
/// hard failures are a file that is not a preset library at all and one
/// written by a newer build, where continuing would mean guessing.
pub fn parse(text: &str) -> Result<Self, LibraryParseError> {
let mut lines = text.lines();
let header = lines.next().unwrap_or_default().trim();
let Some(version) = header.strip_prefix("drpl ") else {
return Err(LibraryParseError::NotALibrary);
};
match version.trim().parse::<u32>() {
Ok(v) if v <= LIBRARY_FORMAT_VERSION => {}
Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)),
Err(_) => return Err(LibraryParseError::NotALibrary),
}
let mut library = Self::default();
let mut current: Option<String> = None;
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(head) = line
.strip_prefix("[preset ")
.and_then(|l| l.strip_suffix(']'))
{
if let Some(name) = current.take() {
library.presets.insert(name, Preset::from_params(params));
params = BTreeMap::new();
}
// A name the writer should never have produced is dropped
// rather than taken: accepting it would mean writing a file
// back out that no longer parses as this one.
match Self::check_name(head) {
Ok(name) => current = Some(name),
Err(_) => {
log::warn!("preset library: unusable preset name {head:?}; skipping");
current = None;
}
}
continue;
}
let Some(name) = current.clone() else {
log::warn!("preset library: line outside any preset: {line}");
continue;
};
match line.split_once('=') {
Some((key, value)) => {
let key = key.trim();
let value = value.trim();
match (key.split_once('.'), value.parse::<f32>()) {
(Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => {
params.insert((op.to_string(), param.to_string()), v);
}
_ => library
.unknown
.entry(name)
.or_default()
.push(line.to_string()),
}
}
None => library
.unknown
.entry(name)
.or_default()
.push(line.to_string()),
}
}
if let Some(name) = current {
library.presets.insert(name, Preset::from_params(params));
}
// A block whose every line was unreadable still produced a preset, and
// an unknown block belonging to no preset would be written back into
// whichever one happened to sort first. Drop the orphans.
library
.unknown
.retain(|k, _| library.presets.contains_key(k));
Ok(library)
}
}
/// Format a value the way the sidecar does — no trailing `.0`, no exponent —
/// so the two files stay comparable line for line.
fn format_value(v: f32) -> String {
let mut s = format!("{v:.6}");
if s.contains('.') {
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
}
if s == "-0" {
s = "0".to_string();
}
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LibraryParseError {
/// The header line was missing or not a `drpl` header.
NotALibrary,
/// Written by a newer build, in a format this one cannot read.
UnsupportedVersion(u32),
}
impl fmt::Display for LibraryParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotALibrary => f.write_str("not a DarkRoom preset library"),
Self::UnsupportedVersion(v) => {
write!(
f,
"preset library format version {v} is newer than this build"
)
}
}
}
}
impl std::error::Error for LibraryParseError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::framing;
use crate::ops::{exposure, saturation, white_balance};
use crate::CropRect;
/// A graph with colour *and* framing moved off default, which is what makes
/// the scope distinction observable.
fn edited() -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
g.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
g.set_param(framing::ID, framing::ANGLE, -2.0);
g
}
#[test]
fn a_copy_carries_only_what_was_changed() {
// The property the whole format rests on, restated for the clipboard:
// a neutral operation contributes nothing, so pasting cannot carry a
// value the source never set.
let preset = Preset::capture(&edited());
assert!(preset
.params()
.contains_key(&("exposure".into(), "exposure".into())));
assert!(
!preset.params().keys().any(|(op, _)| op == saturation::ID.0),
"an untouched operation must not be copied"
);
}
#[test]
fn a_neutral_graph_copies_nothing() {
assert!(Preset::capture(&EditGraph::default_chain()).is_empty());
}
#[test]
fn a_copy_captures_framing_even_though_the_default_scope_drops_it() {
// Capture is deliberately unfiltered: the decision about framing is
// made at paste time, so a user who ticks "include crop" after copying
// must not have to copy again.
let preset = Preset::capture(&edited());
assert!(preset.touches_framing());
}
#[test]
fn pasting_adjustments_leaves_the_targets_composition_alone() {
// The case the default scope exists for: two photographs framed
// differently, made to match in colour without either being re-cropped.
let source = edited();
let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain();
let target_crop = CropRect {
x: 0.0,
y: 0.25,
width: 1.0,
height: 0.5,
};
target.set_crop(target_crop);
preset.apply(&mut target, Scope::Adjustments);
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(0.75),
"the colour must arrive"
);
assert!(
(target.crop().width - target_crop.width).abs() < 1e-5
&& (target.crop().y - target_crop.y).abs() < 1e-5,
"the target's own crop must survive: {:?}",
target.crop()
);
assert_eq!(
target.param(framing::ID, framing::ANGLE),
Some(0.0),
"the source's straightening must not travel on this scope"
);
}
#[test]
fn pasting_everything_carries_the_composition_too() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Everything);
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
assert!(
(target.crop().width - 0.5).abs() < 1e-5,
"{:?}",
target.crop()
);
}
#[test]
fn pasting_replaces_rather_than_overlaying() {
// Pasting a neutral copy must *clear* the target. Were this an
// overlay, "make these match" would leave whatever the target already
// had underneath, and the two images would not in fact match.
let neutral = Preset::capture(&EditGraph::default_chain());
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
neutral.apply(&mut target, Scope::Adjustments);
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
target.param(saturation::ID, saturation::SATURATION),
Some(0.0)
);
}
#[test]
fn a_paste_that_excludes_framing_does_not_clear_the_targets_framing() {
// The other half of the replacement rule: "replace" is scoped. A
// neutral paste must not straighten the target back to zero, or
// excluding framing would still destroy it — just to a different value.
let neutral = Preset::capture(&EditGraph::default_chain());
let mut target = EditGraph::default_chain();
target.set_param(framing::ID, framing::ANGLE, 3.5);
neutral.apply(&mut target, Scope::Adjustments);
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
}
#[test]
fn pasting_leaves_the_view_where_the_user_was_looking() {
// Zoom is navigation, not an edit. A paste that reset it would throw
// the user out of a 4x inspection they were making the comparison at.
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
target.framing_mut().set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.25,
height: 0.25,
});
preset.apply(&mut target, Scope::Everything);
assert!(
target.framing().is_zoomed(),
"the paste threw away the viewport: {:?}",
target.framing().view()
);
}
#[test]
fn a_paste_does_not_disturb_how_the_file_stored_its_pixels() {
// Orientation is a fact about the target's own file. A preset copied
// from a landscape frame must not lay that frame's sensor scan over a
// portrait one — the image would open on its side.
let preset = Preset::capture(&edited());
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
preset.apply(&mut sideways, Scope::Everything);
assert_eq!(
sideways.framing().baseline(),
dr_types::Orientation::from_exif(6)
);
}
#[test]
fn an_unknown_parameter_costs_its_own_line_and_not_the_paste() {
// A clipboard captured by a newer build. The rest must still apply, or
// one unfamiliar operation would silently discard the whole copy.
let mut params = BTreeMap::new();
params.insert(("time_machine".to_string(), "year".to_string()), 1994.0);
params.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
let mut target = EditGraph::default_chain();
Preset::from_params(params).apply(&mut target, Scope::Adjustments);
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
}
#[test]
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
let mut params = BTreeMap::new();
params.insert(("exposure".to_string(), "exposure".to_string()), 99.0);
let mut target = EditGraph::default_chain();
Preset::from_params(params).apply(&mut target, Scope::Adjustments);
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
// --- the batch path, which has no graph ---------------------------------
#[test]
fn amending_a_map_replaces_the_scope_and_spares_the_rest() {
let preset = Preset::capture(&edited());
// A target that has its own crop and its own, different, exposure.
let mut target = BTreeMap::new();
target.insert(("exposure".to_string(), "exposure".to_string()), -1.0);
target.insert(("saturation".to_string(), "saturation".to_string()), 40.0);
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
preset.amend(&mut target, Scope::Adjustments);
assert_eq!(
target.get(&("exposure".to_string(), "exposure".to_string())),
Some(&0.75),
"the copied value must land"
);
assert!(
!target.contains_key(&("saturation".to_string(), "saturation".to_string())),
"an in-scope key the preset does not set must be cleared, not kept"
);
assert_eq!(
target.get(&("framing".to_string(), "crop_w".to_string())),
Some(&0.3),
"the target's own crop must survive an adjustments-only paste"
);
}
#[test]
fn amending_at_full_scope_replaces_the_targets_crop_too() {
let preset = Preset::capture(&edited());
let mut target = BTreeMap::new();
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
preset.amend(&mut target, Scope::Everything);
assert_eq!(
target.get(&("framing".to_string(), "crop_w".to_string())),
Some(&0.5)
);
}
#[test]
fn the_map_path_and_the_graph_path_agree() {
// Two routes to the same result — one through a graph, one through a
// bare map — and a batch apply must not produce a different edit from
// pasting onto the image with it open. Asserted by round-tripping the
// amended map back through a graph and comparing every parameter.
let preset = Preset::capture(&edited());
for scope in [Scope::Adjustments, Scope::Everything] {
let mut target_graph = EditGraph::default_chain();
target_graph.set_param(saturation::ID, saturation::SATURATION, 20.0);
target_graph.set_param(framing::ID, framing::ANGLE, 4.0);
let mut target_map = Preset::capture(&target_graph).into_params();
preset.apply(&mut target_graph, scope);
preset.amend(&mut target_map, scope);
let mut rebuilt = EditGraph::default_chain();
Preset::from_params(target_map).apply(&mut rebuilt, Scope::Everything);
for cap in target_graph.capabilities() {
for p in &cap.params {
assert_eq!(
rebuilt.param(cap.id, p.id),
Some(p.value),
"{scope:?}: {}.{} differs between the graph and map paths",
cap.id,
p.id
);
}
}
}
}
#[test]
fn a_copy_round_trips_through_a_paste() {
// The end-to-end claim the feature makes: after pasting, the target
// holds the source's edit. Checked over the whole chain rather than a
// sample, so an operation that needed special handling would fail here.
let source = edited();
let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Everything);
for cap in source.capabilities() {
for p in &cap.params {
assert_eq!(
target.param(cap.id, p.id),
Some(p.value),
"{}.{} did not survive the copy",
cap.id,
p.id
);
}
}
}
#[test]
fn operations_are_counted_in_scope() {
let preset = Preset::capture(&edited());
// exposure, white_balance and framing were touched.
assert_eq!(preset.op_count(Scope::Everything), 3);
assert_eq!(preset.op_count(Scope::Adjustments), 2);
}
#[test]
fn scope_names_framing_and_nothing_else() {
// The one operation this module knows by name. If a second ever
// appears here, it should be because someone decided it is about the
// picture's shape rather than because it was convenient.
let g = EditGraph::default_chain();
let excluded: Vec<&str> = g
.capabilities()
.iter()
.map(|c| c.id.0)
.filter(|id| !Scope::Adjustments.covers(id))
.collect();
assert_eq!(excluded, vec![framing::ID.0]);
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
fn named() -> PresetLibrary {
let mut lib = PresetLibrary::default();
lib.insert("Warm portrait", Preset::capture(&edited()))
.unwrap();
lib.insert("Neutral", Preset::default()).unwrap();
lib
}
#[test]
fn a_library_round_trips_through_its_text_form() {
let lib = named();
let back = PresetLibrary::parse(&lib.to_text()).unwrap();
assert_eq!(back, lib);
}
#[test]
fn the_same_library_always_writes_the_same_bytes() {
// What lets a caller skip a write by comparing content. Built in the
// opposite order to `named()` so insertion order cannot be what makes
// this pass.
let mut other = PresetLibrary::default();
other.insert("Neutral", Preset::default()).unwrap();
other
.insert("Warm portrait", Preset::capture(&edited()))
.unwrap();
assert_eq!(other.to_text(), named().to_text());
}
#[test]
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
// The empty preset is the "clear these forty frames" action, so it has
// to be a real entry rather than an absence — and a block with no
// lines under it has to parse back as a preset rather than vanish.
let back = PresetLibrary::parse(&named().to_text()).unwrap();
assert_eq!(back.get("Neutral"), Some(&Preset::default()));
}
#[test]
fn an_unreadable_line_costs_that_line_and_not_the_library() {
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
this line is not a setting\nsaturation.amount = not a number\n"
);
let lib = PresetLibrary::parse(&text).unwrap();
let preset = lib.get("Keep").expect("the preset survived");
assert_eq!(
preset.params().get(&("exposure".into(), "exposure".into())),
Some(&0.5)
);
}
#[test]
fn lines_this_build_cannot_read_are_written_back_untouched() {
// The sidecar's version-skew promise, applied here: a build running
// behind must not silently strip what a newer one wrote.
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
something_new_entirely\n"
);
let out = PresetLibrary::parse(&text).unwrap().to_text();
assert!(out.contains("something_new_entirely"), "{out}");
}
#[test]
fn an_unknown_parameter_survives_without_any_machinery_for_it() {
// `Preset` stores whatever keys it is given and resolves them against
// the descriptors only at apply time, so a parameter from a newer
// build needs no preservation path of its own.
let text = format!(
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n"
);
let out = PresetLibrary::parse(&text).unwrap().to_text();
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
}
#[test]
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
assert_eq!(
PresetLibrary::parse(&text),
Err(LibraryParseError::UnsupportedVersion(
LIBRARY_FORMAT_VERSION + 1
))
);
}
#[test]
fn something_that_is_not_a_preset_library_is_refused() {
assert_eq!(
PresetLibrary::parse("drsc 1\n\n[version abc]\n"),
Err(LibraryParseError::NotALibrary)
);
assert_eq!(
PresetLibrary::parse(""),
Err(LibraryParseError::NotALibrary)
);
}
#[test]
fn a_name_that_would_not_parse_back_is_refused() {
// Round-trip safety, not taste: a `]` would close the header early and
// the file would read back as a different library.
let mut lib = PresetLibrary::default();
assert_eq!(
lib.insert("bracket] inside", Preset::default()),
Err(NameError::Unrepresentable)
);
assert_eq!(
lib.insert("two\nlines", Preset::default()),
Err(NameError::Unrepresentable)
);
assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty));
}
#[test]
fn surrounding_whitespace_is_trimmed_rather_than_refused() {
let mut lib = PresetLibrary::default();
lib.insert(" Warm ", Preset::default()).unwrap();
assert!(lib.contains("Warm"));
}
#[test]
fn saving_over_a_name_replaces_it_and_says_so() {
let mut lib = named();
assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true));
assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false));
assert_eq!(lib.get("Warm portrait"), Some(&Preset::default()));
}
#[test]
fn renaming_moves_the_preset_and_leaves_nothing_behind() {
let mut lib = named();
let before = lib.get("Warm portrait").cloned().unwrap();
assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true));
assert!(!lib.contains("Warm portrait"));
assert_eq!(lib.get("Cool portrait"), Some(&before));
}
#[test]
fn renaming_something_that_is_gone_is_not_an_error() {
// Another window may have deleted it since this list was drawn.
let mut lib = named();
assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false));
}
#[test]
fn deleting_reports_whether_there_was_anything_to_delete() {
let mut lib = named();
assert!(lib.remove("Neutral"));
assert!(!lib.remove("Neutral"));
assert_eq!(lib.len(), 1);
}
#[test]
fn a_stored_preset_applies_exactly_as_a_pasted_one_does() {
// The whole point of sharing one representation: a named preset is not
// a second kind of thing with a second apply path.
let lib = named();
let stored = PresetLibrary::parse(&lib.to_text()).unwrap();
let preset = stored.get("Warm portrait").unwrap();
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
// The target keeps its own framing on the default scope.
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
}
#[test]
fn a_stored_preset_amends_a_sidecar_without_a_graph() {
// The batch path: applying to forty images must not build forty
// graphs, so this is the call the library's batch apply makes.
let lib = named();
let preset = lib.get("Warm portrait").unwrap();
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
params.insert(("framing".into(), "angle".into()), 5.0);
params.insert(("exposure".into(), "exposure".into()), -1.0);
preset.amend(&mut params, Scope::Adjustments);
assert_eq!(
params.get(&("exposure".into(), "exposure".into())),
Some(&0.75)
);
// Out of scope, so the target's own crop is untouched.
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
}
}