FR-DEV-6 asks for presets "covering a subset of the edit graph". What landed with the named presets covered two subsets: everything, and everything but the crop. "Match the colour but not the sharpening" had no way to be said. `Scope` is now a set of `Attribute` — the same six kinds every operation already declares and the develop panel already builds its tabs from. The photographer ticking "tone and colour" is naming the groups they navigate by, and neither this module nor the interface has to name an operation to do it (FR-DEV-3c). The pleasing part is what left. Framing used to be excluded by an explicit test against one operation's id; it is now excluded because Geometry is not in the default set. The special case dissolved into the general rule, and the argument for it — a crop is a decision about *this* photograph, and carrying it across forty destroys forty compositions — is now a statement about a kind of edit rather than about a node. All thirty-three existing preset tests pass unchanged, which is the evidence that the generalisation kept its promises. One decision that is a field rather than a rule, because the two cases genuinely differ. An operation this build cannot classify — from a newer version, arriving over sync — travels under "everything" and "everything but the crop", because those are claims about the whole edit and an unrecognised operation is part of it (FR-NC-8). It does not travel under a hand-picked set, because that is a claim about kinds, and an unknown kind is not one of the kinds that were ticked. The settings page's "Copy crop and rotation" checkbox is gone, replaced by the same chips the preset sheet draws. It asked the right first question — geometry is the kind whose accidental travel destroys work — but it was the only question a boolean could ask. The field stays in `Settings`, read exactly once to seed the new set, so anyone who had ticked it keeps their behaviour. The chips are deliberately not in the develop column. Six of them there would set the width of the whole sidebar, which is the bug `ChipGrid`'s comment records at length. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1325 lines
52 KiB
Rust
1325 lines
52 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::{Attribute, OpId, ParamId};
|
|
use crate::graph::EditGraph;
|
|
|
|
/// Which parts of an edit a copy carries.
|
|
///
|
|
/// # A set of kinds, not a list of operations
|
|
///
|
|
/// Every operation declares what it is *about* — [`Attribute::Tone`],
|
|
/// [`Attribute::Colour`], [`Attribute::Detail`] and so on (ARCH §4.3a) — and
|
|
/// a scope is a set of those. That is the same vocabulary the develop panel
|
|
/// builds its tabs from, which is the point: the photographer ticking "tone
|
|
/// and colour" is naming the same groups they already navigate by, and
|
|
/// nothing here or in the interface has to name an operation to do it
|
|
/// (FR-DEV-3c).
|
|
///
|
|
/// It also dissolves a special case. Framing used to be excluded by an
|
|
/// explicit test against one operation's id; it is now excluded because
|
|
/// [`Attribute::Geometry`] is not in the default set, and the argument below
|
|
/// is a statement about a kind of edit rather than about a particular node.
|
|
///
|
|
/// # Why geometry is out by default
|
|
///
|
|
/// 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 [`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.
|
|
///
|
|
/// # An operation with two attributes travels with either
|
|
///
|
|
/// `tone_curve` declares both tone and colour, and a scope naming either one
|
|
/// carries it. The alternative — requiring every declared attribute to be
|
|
/// selected — would make the curve unreachable unless both were ticked, which
|
|
/// is not what "include the tone work" means to the person ticking it.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Scope {
|
|
/// One bit per [`Attribute`], indexed by its position in [`Attribute::ALL`].
|
|
bits: u8,
|
|
/// Whether operations this build cannot classify travel too.
|
|
///
|
|
/// An operation from a newer build reaches us as a name in a file with no
|
|
/// descriptor behind it, so there is no attribute to match it against.
|
|
/// What to do about it is genuinely different for the two kinds of scope,
|
|
/// which is why this is a field rather than a rule:
|
|
///
|
|
/// * [`Scope::adjustments`] and [`Scope::everything`] are statements about
|
|
/// the *whole edit* — "all of it", or "all of it but the crop". An
|
|
/// operation nobody here recognises is still part of the whole edit, so
|
|
/// it travels, and a preset made on a newer device still applies on an
|
|
/// older one. That matters in an application that syncs between devices
|
|
/// which may not be on the same version (FR-NC-8).
|
|
/// * A hand-picked set is a statement about *kinds*: "the tone work, not
|
|
/// the colour". An operation whose kind is unknown is not one of the
|
|
/// kinds that were picked, so it stays behind rather than being smuggled
|
|
/// in under a selection that never named it.
|
|
carries_unclassified: bool,
|
|
}
|
|
|
|
impl Default for Scope {
|
|
fn default() -> Self {
|
|
Self::adjustments()
|
|
}
|
|
}
|
|
|
|
impl Scope {
|
|
/// Colour and tone and everything else the photograph is made of, but not
|
|
/// its shape. The default.
|
|
pub fn adjustments() -> Self {
|
|
Self {
|
|
bits: Self::all_bits() & !Self::bit(Attribute::Geometry),
|
|
carries_unclassified: true,
|
|
}
|
|
}
|
|
|
|
/// The whole edit, framing included.
|
|
pub fn everything() -> Self {
|
|
Self {
|
|
bits: Self::all_bits(),
|
|
carries_unclassified: true,
|
|
}
|
|
}
|
|
|
|
/// Exactly these kinds and nothing else.
|
|
///
|
|
/// See `carries_unclassified`: a hand-picked set deliberately leaves
|
|
/// behind operations this build cannot classify.
|
|
pub fn of(attributes: impl IntoIterator<Item = Attribute>) -> Self {
|
|
let bits = attributes.into_iter().fold(0, |acc, a| acc | Self::bit(a));
|
|
Self {
|
|
bits,
|
|
carries_unclassified: false,
|
|
}
|
|
}
|
|
|
|
/// 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 attributes_of(op) {
|
|
Some(attributes) => attributes.iter().any(|a| self.has(*a)),
|
|
None => self.carries_unclassified,
|
|
}
|
|
}
|
|
|
|
/// Whether this scope names a kind.
|
|
pub fn has(self, attribute: Attribute) -> bool {
|
|
self.bits & Self::bit(attribute) != 0
|
|
}
|
|
|
|
/// The kinds this scope names, in declaration order.
|
|
pub fn attributes(self) -> impl Iterator<Item = Attribute> {
|
|
Attribute::ALL.into_iter().filter(move |a| self.has(*a))
|
|
}
|
|
|
|
/// Whether this scope names nothing at all.
|
|
///
|
|
/// A scope that reaches nothing is a legitimate thing to hold — it is what
|
|
/// an interface has while the photographer is still un-ticking boxes — and
|
|
/// applying it is a no-op rather than an error.
|
|
pub fn is_empty(self) -> bool {
|
|
self.bits == 0
|
|
}
|
|
|
|
fn bit(attribute: Attribute) -> u8 {
|
|
1 << Attribute::ALL
|
|
.iter()
|
|
.position(|a| *a == attribute)
|
|
.expect("every attribute is in ALL") as u8
|
|
}
|
|
|
|
fn all_bits() -> u8 {
|
|
Attribute::ALL.iter().fold(0, |acc, a| acc | Self::bit(*a))
|
|
}
|
|
}
|
|
|
|
/// The attributes an operation declares, without building a graph.
|
|
///
|
|
/// The batch path needs this: applying to forty images amends forty parameter
|
|
/// maps and never instantiates the chain (see [`Preset::amend`]), so asking a
|
|
/// graph what an operation is about would mean building one for the sole
|
|
/// purpose of reading a constant off it.
|
|
///
|
|
/// Built once from the default chain, which is where the declarations already
|
|
/// live — a second table listing operations by hand would be a second place to
|
|
/// forget an entry, and this module names no operation (see the module note).
|
|
fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
|
|
static TABLE: std::sync::LazyLock<BTreeMap<String, Vec<Attribute>>> =
|
|
std::sync::LazyLock::new(|| {
|
|
EditGraph::default_chain()
|
|
.capabilities()
|
|
.into_iter()
|
|
.map(|cap| (cap.id.0.to_string(), cap.attributes))
|
|
.collect()
|
|
});
|
|
TABLE.get(op).map(Vec::as_slice)
|
|
}
|
|
|
|
/// 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, _)| attributes_of(op).is_some_and(|a| a.contains(&Attribute::Geometry)))
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Partial scope
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
|
|
let preset = Preset::capture(&edited());
|
|
let mut target = EditGraph::default_chain();
|
|
preset.apply(&mut target, Scope::of([Attribute::Tone]));
|
|
|
|
// Tone was picked, so the exposure travelled.
|
|
assert_eq!(
|
|
target.param(exposure::ID, exposure::EXPOSURE),
|
|
Some(0.75),
|
|
"the tone work should have travelled"
|
|
);
|
|
// Colour was not, so the white balance stayed behind.
|
|
assert_eq!(
|
|
target.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(0.0),
|
|
"the colour work should have stayed behind"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_operation_with_two_attributes_travels_with_either_of_them() {
|
|
// `tone_curve` declares tone *and* colour. Requiring both would make
|
|
// the curve unreachable unless both were ticked, which is not what
|
|
// "include the tone work" means to the person ticking it.
|
|
let curve = "tone_curve";
|
|
assert!(Scope::of([Attribute::Tone]).covers(curve));
|
|
assert!(Scope::of([Attribute::Colour]).covers(curve));
|
|
assert!(!Scope::of([Attribute::Detail]).covers(curve));
|
|
}
|
|
|
|
#[test]
|
|
fn geometry_is_the_only_thing_the_default_scope_leaves_out() {
|
|
let default_scope = Scope::adjustments();
|
|
let excluded: Vec<Attribute> = Attribute::ALL
|
|
.into_iter()
|
|
.filter(|a| !default_scope.has(*a))
|
|
.collect();
|
|
assert_eq!(excluded, vec![Attribute::Geometry]);
|
|
assert!(Scope::everything().has(Attribute::Geometry));
|
|
}
|
|
|
|
#[test]
|
|
fn a_whole_edit_scope_carries_an_operation_this_build_cannot_classify() {
|
|
// The version-skew case, and the reason `carries_unclassified` is a
|
|
// field rather than a rule: a preset made on a newer device still
|
|
// applies on an older one (FR-NC-8).
|
|
assert!(Scope::adjustments().covers("an_operation_from_the_future"));
|
|
assert!(Scope::everything().covers("an_operation_from_the_future"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_hand_picked_scope_leaves_an_unclassifiable_operation_behind() {
|
|
// "The tone work, not the colour" is a statement about kinds, and an
|
|
// operation whose kind is unknown is not one of the kinds picked. It
|
|
// must not be smuggled in under a selection that never named it.
|
|
let picked = Scope::of([Attribute::Tone, Attribute::Colour]);
|
|
assert!(!picked.covers("an_operation_from_the_future"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_scope_naming_nothing_moves_nothing_and_destroys_nothing() {
|
|
// What an interface holds while the photographer is still un-ticking
|
|
// boxes. Applying it must be a no-op rather than a reset.
|
|
let empty = Scope::of([]);
|
|
assert!(empty.is_empty());
|
|
|
|
let mut target = EditGraph::default_chain();
|
|
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
|
|
Preset::capture(&edited()).apply(&mut target, empty);
|
|
assert_eq!(
|
|
target.param(exposure::ID, exposure::EXPOSURE),
|
|
Some(-1.25),
|
|
"an empty scope cleared the target"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_scope_amends_a_map_without_touching_it() {
|
|
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
|
params.insert(("exposure".into(), "exposure".into()), -1.25);
|
|
let before = params.clone();
|
|
Preset::capture(&edited()).amend(&mut params, Scope::of([]));
|
|
assert_eq!(params, before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_scope_lists_the_kinds_it_names_in_declaration_order() {
|
|
// Declaration order is roughly the order a photographer works in, and
|
|
// an interface drawing chips from this should not have to sort them.
|
|
let scope = Scope::of([Attribute::Detail, Attribute::Tone]);
|
|
assert_eq!(
|
|
scope.attributes().collect::<Vec<_>>(),
|
|
vec![Attribute::Tone, Attribute::Detail]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_attribute_name_survives_the_round_trip_it_is_stored_through() {
|
|
// These strings go into settings and come back. A change to one would
|
|
// silently reset a photographer's choice of what a paste carries.
|
|
for attribute in Attribute::ALL {
|
|
assert_eq!(Attribute::from_name(attribute.name()), Some(attribute));
|
|
}
|
|
}
|
|
}
|