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>
1083 lines
42 KiB
Rust
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));
|
|
}
|
|
}
|