//! 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::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) -> 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 { 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>> = 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) } /// 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::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 { 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 ]`, 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, /// 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>, } 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 { 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 { 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 { 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 { 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 { 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 { 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::() { 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 = 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::()) { (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()); } /// 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()); 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::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); 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![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)); } } }