A preset could not choose a film stock. The stock is a choice of material rather than a parameter, so `Preset` — a map of `op.param = value` — had nowhere to hold it, and "Portra 400, printed" could not be saved, copied or shipped as a look. Worse, the film node's own sliders *were* parameters: a paste moved one stock's exposure and push onto whatever stock the target was on, and left the target's tables baked from the values it had just replaced. A preset now carries a `FilmRef` beside its parameters. It travels under whichever scope carries the film node, so the stock and its sliders are never split, and by the replacement rule every other parameter follows: applied at that scope, a preset without a film develops the target without one. `Preset::apply` returns the `FilmRebake` it owes, as `EditGraph::set_state` already did, because this crate cannot bake a stock; the develop session pays it before recording the step, and the batch paste writes the stock into each sidecar through `film_for`. The library file spells it `film =` / `film_print =`, as a sidecar does, and an older build keeps those lines as ones it does not understand. `EditState` keeps the film in its own field only: the parameters it captures leave it out, so one edit has one place to say which stock it is on. Second, a preset now has a reach. Replacement is right for a copy of a whole edit — "make these match" — and wrong for a look: a stock-only "Portra 400" applied that way would put the photograph's exposure, white balance and noise reduction back to default. `Reach::Named` replaces only the operations a preset names (whole operations, so a look that sets the blacks resets the whites beside them) and the film only if it names one. Saved edits and the clipboard keep `Reach::Whole`; the line `reach = named` is written only for the other, so existing libraries write the same bytes.
1787 lines
70 KiB
Rust
1787 lines
70 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;
|
|
use crate::state::{FilmRebake, FilmRef};
|
|
|
|
/// 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::Compose`] 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::Compose),
|
|
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
|
|
}
|
|
|
|
/// Whether this scope carries the film — the stock as well as its sliders.
|
|
///
|
|
/// Asked of the film node's own classification rather than decided here,
|
|
/// so the stock travels under exactly the scopes its exposure slider
|
|
/// does. Splitting the two would put one stock's exposure on another.
|
|
pub fn carries_film(self) -> bool {
|
|
self.covers(crate::ops::film_sim::ID.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(|| {
|
|
let mut chain = EditGraph::default_chain();
|
|
// With a profile in place, because one capability only exists once
|
|
// a photograph has brought a lens profile with it — the switch
|
|
// that accepts or declines it. Table entries are what
|
|
// [`Scope::covers`] classifies by, and an id missing from here
|
|
// falls through to `carries_unclassified`: the switch would then
|
|
// travel with a scope that named only tone, which is precisely
|
|
// what a scope is for refusing.
|
|
chain.set_lens_profile(Some(crate::lens::LensProfile::default()));
|
|
chain
|
|
.capabilities()
|
|
.into_iter()
|
|
.map(|cap| (cap.id.0.to_string(), cap.attributes))
|
|
.collect()
|
|
});
|
|
TABLE.get(op).map(Vec::as_slice)
|
|
}
|
|
|
|
/// How much of the target an applied preset replaces. See the module note.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum Reach {
|
|
/// Everything in scope: absence means default. A copy, a saved edit.
|
|
#[default]
|
|
Whole,
|
|
/// Only the operations the preset names, and the film if it names one.
|
|
/// Everything else in the target is left as it was. A look.
|
|
Named,
|
|
}
|
|
|
|
/// 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>,
|
|
reach: Reach,
|
|
/// TRACES: FR-DEV-3f
|
|
/// The stock this edit develops on, by id. See the module note.
|
|
film: Option<FilmRef>,
|
|
}
|
|
|
|
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 {
|
|
Self {
|
|
film: graph.film().map(|f| FilmRef {
|
|
stock: f.stock.clone(),
|
|
print: f.print.clone(),
|
|
}),
|
|
..Self::capture_params(graph)
|
|
}
|
|
}
|
|
|
|
/// The parameters alone, without the film.
|
|
///
|
|
/// For [`EditGraph::state`], which carries the film in a field of its own
|
|
/// ([`crate::EditState::film`]). Capturing it twice would give one edit
|
|
/// two places to disagree about its stock.
|
|
pub(crate) fn capture_params(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::from_params(params)
|
|
}
|
|
|
|
/// Build from an already-captured parameter map — a sidecar's, typically.
|
|
pub fn from_params(params: BTreeMap<(String, String), f32>) -> Self {
|
|
Self {
|
|
params,
|
|
reach: Reach::Whole,
|
|
film: None,
|
|
}
|
|
}
|
|
|
|
/// The same preset, reaching as far as `reach` says.
|
|
pub fn with_reach(self, reach: Reach) -> Self {
|
|
Self { reach, ..self }
|
|
}
|
|
|
|
/// How much of the target this preset replaces.
|
|
pub fn reach(&self) -> Reach {
|
|
self.reach
|
|
}
|
|
|
|
/// Whether applying this preset replaces `op` — before any scope is asked.
|
|
fn reaches(&self, op: &str) -> bool {
|
|
match self.reach {
|
|
Reach::Whole => true,
|
|
Reach::Named => {
|
|
self.params.keys().any(|(o, _)| o == op)
|
|
|| (self.film.is_some() && op == crate::ops::film_sim::ID.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The same preset, developed on `film`.
|
|
pub fn with_film(self, film: Option<FilmRef>) -> Self {
|
|
Self { film, ..self }
|
|
}
|
|
|
|
/// The stock this preset develops on, if it names one.
|
|
pub fn film(&self) -> Option<&FilmRef> {
|
|
self.film.as_ref()
|
|
}
|
|
|
|
/// What applying at `scope` does to the target's film.
|
|
///
|
|
/// `None` when the scope leaves the film alone; `Some(None)` when it
|
|
/// develops the target without one. The same two levels the sidecar
|
|
/// writer takes, which is who asks: the batch path amends files rather
|
|
/// than graphs, and has to know whether the film line is being written at
|
|
/// all before it knows what to write.
|
|
pub fn film_for(&self, scope: Scope) -> Option<Option<&FilmRef>> {
|
|
(scope.carries_film() && self.reaches(crate::ops::film_sim::ID.0))
|
|
.then_some(self.film.as_ref())
|
|
}
|
|
|
|
/// 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() && self.film.is_none()
|
|
}
|
|
|
|
/// 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::Compose)))
|
|
}
|
|
|
|
/// 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 {
|
|
// A stock with every film slider at default is still the film node
|
|
// at work, and a preset holding nothing else is not "Neutral".
|
|
let film = self.film.is_some().then_some(crate::ops::film_sim::ID.0);
|
|
let mut ops: Vec<&str> = self
|
|
.params
|
|
.keys()
|
|
.map(|(op, _)| op.as_str())
|
|
.chain(film)
|
|
.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.
|
|
///
|
|
/// The **film** is replaced when the scope carries it, and handed back as
|
|
/// a [`FilmRebake`] because this crate cannot bake a stock — the same debt
|
|
/// [`EditGraph::set_state`] returns, for the same reason. It is cleared
|
|
/// even when the preset names the stock already on the graph: the tables
|
|
/// were baked from the film sliders this call has just replaced.
|
|
pub fn apply(&self, graph: &mut EditGraph, scope: Scope) -> FilmRebake {
|
|
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) && self.reaches(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);
|
|
|
|
match self.film_for(scope) {
|
|
None => FilmRebake::NotNeeded,
|
|
Some(film) => {
|
|
graph.set_film(None);
|
|
match film {
|
|
None => FilmRebake::NotNeeded,
|
|
Some(film) => FilmRebake::Wanted(film.clone()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// A parameter map has no film in it, so the stock is not written here:
|
|
/// the caller asks [`Self::film_for`] and writes it beside the map.
|
|
pub fn amend(&self, target: &mut BTreeMap<(String, String), f32>, scope: Scope) {
|
|
target.retain(|(op, _), _| !(scope.covers(op) && self.reaches(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()
|
|
}
|
|
|
|
/// How many lines were kept without being understood.
|
|
///
|
|
/// For a file this build wrote itself, or shipped, that should be none: a
|
|
/// misspelt key is preserved faithfully and does nothing.
|
|
pub fn unread_lines(&self) -> usize {
|
|
self.unknown.values().map(Vec::len).sum()
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
// TRACES: FR-DEV-3f
|
|
// Spelled as the sidecar spells them, so a block can still be
|
|
// pasted from one file into the other. A build that predates
|
|
// these lines reads them as lines it does not understand and
|
|
// writes them back untouched, which is the promise below.
|
|
// Only when it differs from the default, so every library written
|
|
// before looks existed still writes the same bytes.
|
|
if preset.reach() == Reach::Named {
|
|
let _ = writeln!(out, "reach = named");
|
|
}
|
|
if let Some(film) = preset.film() {
|
|
let _ = writeln!(out, "film = {}", film.stock);
|
|
if let Some(print) = &film.print {
|
|
let _ = writeln!(out, "film_print = {print}");
|
|
}
|
|
}
|
|
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();
|
|
let mut film: Option<FilmRef> = None;
|
|
let mut reach = Reach::Whole;
|
|
|
|
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(std::mem::take(&mut params))
|
|
.with_film(film.take())
|
|
.with_reach(reach),
|
|
);
|
|
}
|
|
params.clear();
|
|
film = None;
|
|
reach = Reach::Whole;
|
|
// 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('=') {
|
|
// TRACES: FR-DEV-3f
|
|
// Not checked against the installed stocks: this crate does
|
|
// not link them, and a preset naming a stock this device
|
|
// lacks must survive being stored here. Whoever bakes it
|
|
// reports the miss — the sidecar's rule, for its reason.
|
|
// `get_or_insert_with` because a hand-edited block may name
|
|
// the paper first.
|
|
Some((key, value)) if key.trim() == "reach" && value.trim() == "named" => {
|
|
reach = Reach::Named;
|
|
}
|
|
Some((key, value)) if key.trim() == "film" && !value.trim().is_empty() => {
|
|
film.get_or_insert_with(FilmRef::default).stock = value.trim().to_string();
|
|
}
|
|
Some((key, value)) if key.trim() == "film_print" && !value.trim().is_empty() => {
|
|
film.get_or_insert_with(FilmRef::default).print =
|
|
Some(value.trim().to_string());
|
|
}
|
|
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)
|
|
.with_film(film)
|
|
.with_reach(reach),
|
|
);
|
|
}
|
|
|
|
// A paper with no film named beside it is a print of nothing. Dropped
|
|
// rather than kept, since baking it would have no stock to start from.
|
|
for preset in library.presets.values_mut() {
|
|
if preset.film.as_ref().is_some_and(|f| f.stock.is_empty()) {
|
|
log::warn!("preset library: a paper without a film; ignoring it");
|
|
preset.film = None;
|
|
}
|
|
}
|
|
|
|
// 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.set_param(framing::ID, framing::KEYSTONE_V, 40.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());
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DEV-3c
|
|
/// The lens profile switch is classified, so a scope can refuse it.
|
|
///
|
|
/// It is the one capability that exists only once a photograph has brought
|
|
/// a lens profile with it, and the attribute table is built from a chain
|
|
/// — so it was missing from that table before the chain used to build it
|
|
/// was given a profile. An unclassified id falls through to
|
|
/// `carries_unclassified`, and a copy of somebody's tone edit would have
|
|
/// arrived carrying "do not correct this lens".
|
|
#[test]
|
|
fn declining_a_lens_profile_is_an_optical_edit() {
|
|
use crate::lens::profile_switch;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_lens_profile(Some(crate::lens::LensProfile::default()));
|
|
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
|
|
|
let preset = Preset::capture(&g);
|
|
assert!(
|
|
preset
|
|
.params()
|
|
.contains_key(&(profile_switch::ID.0.into(), profile_switch::APPLY.0.into())),
|
|
"declining the profile is an edit and must be copyable"
|
|
);
|
|
|
|
assert!(Scope::of([Attribute::Optics]).covers(profile_switch::ID.0));
|
|
assert!(!Scope::of([Attribute::Tone]).covers(profile_switch::ID.0));
|
|
}
|
|
|
|
#[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())
|
|
.expect_no_film();
|
|
|
|
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"
|
|
);
|
|
// TRACES: FR-DEV-20
|
|
// Perspective is composition on the same terms as the crop: it is
|
|
// withheld by the default scope, not pasted over the target's frame.
|
|
assert_eq!(
|
|
target.param(framing::ID, framing::KEYSTONE_V),
|
|
Some(0.0),
|
|
"the source's keystone 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())
|
|
.expect_no_film();
|
|
|
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
|
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.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())
|
|
.expect_no_film();
|
|
|
|
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())
|
|
.expect_no_film();
|
|
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())
|
|
.expect_no_film();
|
|
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())
|
|
.expect_no_film();
|
|
|
|
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())
|
|
.expect_no_film();
|
|
|
|
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())
|
|
.expect_no_film();
|
|
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).expect_no_film();
|
|
preset.amend(&mut target_map, scope);
|
|
|
|
let mut rebuilt = EditGraph::default_chain();
|
|
Preset::from_params(target_map)
|
|
.apply(&mut rebuilt, Scope::everything())
|
|
.expect_no_film();
|
|
|
|
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())
|
|
.expect_no_film();
|
|
|
|
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]);
|
|
}
|
|
|
|
// --- the film ------------------------------------------------------------
|
|
|
|
/// A graph developing on `stock`. The tables are invented; what is under
|
|
/// test is whether the *choice* travels.
|
|
fn on_film(stock: &str) -> EditGraph {
|
|
let mut g = edited();
|
|
g.set_film(Some(crate::graph::Film {
|
|
stock: stock.to_string(),
|
|
print: Some("kodak_portra_endura".to_string()),
|
|
tables: crate::ops::FilmTables {
|
|
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
|
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
|
curve_log_min: -3.0,
|
|
curve_log_max: 1.0,
|
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
|
density_max: 2.0,
|
|
lut_size: 2,
|
|
grain_particles: [0.0; 3],
|
|
grain_density_max: [2.0; 3],
|
|
grain_uniformity: 1.0,
|
|
},
|
|
}));
|
|
g
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
#[test]
|
|
fn a_copy_carries_the_stock_it_was_developed_on() {
|
|
let preset = Preset::capture(&on_film("kodak_portra_400"));
|
|
let film = preset.film().expect("the stock was captured");
|
|
assert_eq!(film.stock, "kodak_portra_400");
|
|
assert_eq!(film.print.as_deref(), Some("kodak_portra_endura"));
|
|
assert!(Preset::capture(&edited()).film().is_none());
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
/// The graph's own state keeps the film in one place only.
|
|
#[test]
|
|
fn an_edit_state_does_not_carry_the_film_twice() {
|
|
let state = on_film("kodak_portra_400").state();
|
|
assert!(state.film.is_some());
|
|
assert_eq!(state.params.film(), None);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
/// Applying a preset that names a stock asks for it to be baked, and
|
|
/// clears the tables it found — they came from the sliders it replaced.
|
|
#[test]
|
|
fn applying_a_film_preset_asks_for_its_stock() {
|
|
let preset = Preset::capture(&on_film("kodak_portra_400"));
|
|
let mut target = on_film("ilford_hp5");
|
|
|
|
let rebake = preset.apply(&mut target, Scope::adjustments());
|
|
assert_eq!(
|
|
rebake.wanted().map(|f| f.stock.as_str()),
|
|
Some("kodak_portra_400")
|
|
);
|
|
assert!(target.film().is_none(), "the old tables were left standing");
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
/// Replacement, as for every parameter: a preset with no stock, at a
|
|
/// scope that carries the film, develops the target without one.
|
|
#[test]
|
|
fn a_preset_without_a_film_clears_the_targets() {
|
|
let mut target = on_film("ilford_hp5");
|
|
Preset::capture(&edited())
|
|
.apply(&mut target, Scope::adjustments())
|
|
.expect_no_film();
|
|
assert!(target.film().is_none());
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
/// A scope that leaves the film node behind leaves the stock behind too,
|
|
/// so an exposure never lands on a stock it was not set for.
|
|
#[test]
|
|
fn a_scope_without_the_film_leaves_the_stock_alone() {
|
|
let preset = Preset::capture(&on_film("kodak_portra_400"));
|
|
let mut target = on_film("ilford_hp5");
|
|
|
|
let tone = Scope::of([Attribute::Tone]);
|
|
assert!(!tone.carries_film());
|
|
preset.apply(&mut target, tone).expect_no_film();
|
|
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
|
|
assert_eq!(preset.film_for(tone), None);
|
|
assert!(preset.film_for(Scope::adjustments()).is_some());
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
#[test]
|
|
fn a_stock_alone_is_not_a_neutral_preset() {
|
|
let preset = Preset::default().with_film(Some(FilmRef {
|
|
stock: "kodak_portra_400".into(),
|
|
print: None,
|
|
}));
|
|
assert!(!preset.is_empty());
|
|
assert_eq!(preset.op_count(Scope::adjustments()), 1);
|
|
assert_eq!(preset.op_count(Scope::of([Attribute::Tone])), 0);
|
|
}
|
|
|
|
// --- reach ---------------------------------------------------------------
|
|
|
|
/// A look: a stock and a contrast, and nothing else.
|
|
fn look() -> Preset {
|
|
let mut params = BTreeMap::new();
|
|
params.insert(("contrast".to_string(), "contrast".to_string()), 20.0);
|
|
Preset::from_params(params)
|
|
.with_film(Some(FilmRef {
|
|
stock: "kodak_portra_400".into(),
|
|
print: None,
|
|
}))
|
|
.with_reach(Reach::Named)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6
|
|
/// The reason `Reach` exists: a look applied over a corrected photograph
|
|
/// keeps the correction.
|
|
#[test]
|
|
fn a_look_leaves_what_it_does_not_name_alone() {
|
|
let mut target = EditGraph::default_chain();
|
|
target.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
|
|
target.set_param(saturation::ID, saturation::SATURATION, -40.0);
|
|
|
|
let rebake = look().apply(&mut target, Scope::adjustments());
|
|
|
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
|
|
assert_eq!(
|
|
target.param(saturation::ID, saturation::SATURATION),
|
|
Some(-40.0)
|
|
);
|
|
assert_eq!(
|
|
rebake.wanted().map(|f| f.stock.as_str()),
|
|
Some("kodak_portra_400")
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6
|
|
/// A look without a film leaves the target's film alone, where a whole
|
|
/// edit without one would clear it.
|
|
#[test]
|
|
fn a_look_without_a_film_keeps_the_targets() {
|
|
let mut target = on_film("ilford_hp5");
|
|
let only_contrast = look().with_film(None);
|
|
assert_eq!(only_contrast.film_for(Scope::adjustments()), None);
|
|
only_contrast
|
|
.apply(&mut target, Scope::adjustments())
|
|
.expect_no_film();
|
|
assert_eq!(target.film().map(|f| f.stock.as_str()), Some("ilford_hp5"));
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6
|
|
/// The batch path agrees: a look amends only the operations it names.
|
|
#[test]
|
|
fn a_look_amends_only_what_it_names() {
|
|
let mut target = BTreeMap::new();
|
|
target.insert(("exposure".to_string(), "exposure".to_string()), 1.25);
|
|
target.insert(("contrast".to_string(), "contrast".to_string()), -50.0);
|
|
look().amend(&mut target, Scope::adjustments());
|
|
assert_eq!(
|
|
target.get(&("exposure".to_string(), "exposure".to_string())),
|
|
Some(&1.25)
|
|
);
|
|
assert_eq!(
|
|
target.get(&("contrast".to_string(), "contrast".to_string())),
|
|
Some(&20.0)
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6
|
|
#[test]
|
|
fn a_look_survives_the_library_round_trip() {
|
|
let mut lib = named();
|
|
lib.insert("Look", look()).unwrap();
|
|
let text = lib.to_text();
|
|
assert!(text.contains("reach = named\n"), "{text}");
|
|
let back = PresetLibrary::parse(&text).unwrap();
|
|
assert_eq!(back, lib);
|
|
// And the default is not written, so an existing library's bytes are
|
|
// unchanged by this format ever having grown the line.
|
|
assert_eq!(named().to_text().matches("reach").count(), 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}");
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
#[test]
|
|
fn a_film_survives_the_library_round_trip() {
|
|
let mut lib = named();
|
|
lib.insert("Portra", Preset::capture(&on_film("kodak_portra_400")))
|
|
.unwrap();
|
|
let text = lib.to_text();
|
|
assert!(text.contains("film = kodak_portra_400\n"), "{text}");
|
|
assert!(
|
|
text.contains("film_print = kodak_portra_endura\n"),
|
|
"{text}"
|
|
);
|
|
assert_eq!(PresetLibrary::parse(&text).unwrap(), lib);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
/// The paper may come first in a hand-edited block, and a paper with no
|
|
/// film is dropped rather than baked from nothing.
|
|
#[test]
|
|
fn film_lines_read_in_either_order_and_a_lone_paper_is_dropped() {
|
|
let text = format!(
|
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset A]\nfilm_print = p\nfilm = s\n\n\
|
|
[preset B]\nfilm_print = p\nexposure.exposure = 1\n"
|
|
);
|
|
let lib = PresetLibrary::parse(&text).unwrap();
|
|
let a = lib.get("A").unwrap().film().unwrap();
|
|
assert_eq!((a.stock.as_str(), a.print.as_deref()), ("s", Some("p")));
|
|
assert_eq!(lib.get("B").unwrap().film(), None);
|
|
assert_eq!(lib.get("B").unwrap().len(), 1);
|
|
}
|
|
|
|
#[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())
|
|
.expect_no_film();
|
|
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]))
|
|
.expect_no_film();
|
|
|
|
// 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::Compose]);
|
|
assert!(Scope::everything().has(Attribute::Compose));
|
|
}
|
|
|
|
#[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)
|
|
.expect_no_film();
|
|
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));
|
|
}
|
|
}
|
|
}
|