Let a paste carry some kinds of edit and not others

FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.

`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).

The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.

One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.

The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.

The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 20:38:19 +02:00
co-authored by Claude Opus 5
parent eb39599f12
commit bb35665bd2
11 changed files with 689 additions and 133 deletions
+280 -38
View File
@@ -24,8 +24,8 @@
//! 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
//! 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.
//!
@@ -42,25 +42,105 @@ use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use crate::descriptor::{OpId, ParamId};
use crate::descriptor::{Attribute, OpId, ParamId};
use crate::graph::EditGraph;
/// Which part of an edit a copy carries.
/// Which parts of an edit a copy carries.
///
/// Two variants rather than a per-operation mask because the distinction being
/// drawn is not "which operations" but "is this about the picture's colour or
/// about its shape". Everything in the chain but framing answers the first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Scope {
/// Colour and tone. The target keeps its own crop, straightening,
/// rotation and flips.
#[default]
Adjustments,
/// The whole edit, framing included.
Everything,
/// # A set of kinds, not a list of operations
///
/// Every operation declares what it is *about* — [`Attribute::Tone`],
/// [`Attribute::Colour`], [`Attribute::Detail`] and so on (ARCH §4.3a) — and
/// a scope is a set of those. That is the same vocabulary the develop panel
/// builds its tabs from, which is the point: the photographer ticking "tone
/// and colour" is naming the same groups they already navigate by, and
/// nothing here or in the interface has to name an operation to do it
/// (FR-DEV-3c).
///
/// It also dissolves a special case. Framing used to be excluded by an
/// explicit test against one operation's id; it is now excluded because
/// [`Attribute::Geometry`] is not in the default set, and the argument below
/// is a statement about a kind of edit rather than about a particular node.
///
/// # Why geometry is out by default
///
/// A crop is a decision about *this* photograph's composition. Copying colour
/// from one frame to the next is what a photographer means by "make these
/// match"; copying the crop as well re-frames every one of them to a rectangle
/// chosen while looking at a different picture, and on a batch of forty that is
/// forty compositions destroyed by one action.
///
/// So [`Scope::adjustments`] leaves the target's framing where it is, and
/// [`Scope::everything`] is available for the case the exclusion exists to
/// protect against being impossible otherwise — applying one aspect ratio
/// across a shoot.
///
/// # An operation with two attributes travels with either
///
/// `tone_curve` declares both tone and colour, and a scope naming either one
/// carries it. The alternative — requiring every declared attribute to be
/// selected — would make the curve unreachable unless both were ticked, which
/// is not what "include the tone work" means to the person ticking it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
/// One bit per [`Attribute`], indexed by its position in [`Attribute::ALL`].
bits: u8,
/// Whether operations this build cannot classify travel too.
///
/// An operation from a newer build reaches us as a name in a file with no
/// descriptor behind it, so there is no attribute to match it against.
/// What to do about it is genuinely different for the two kinds of scope,
/// which is why this is a field rather than a rule:
///
/// * [`Scope::adjustments`] and [`Scope::everything`] are statements about
/// the *whole edit* — "all of it", or "all of it but the crop". An
/// operation nobody here recognises is still part of the whole edit, so
/// it travels, and a preset made on a newer device still applies on an
/// older one. That matters in an application that syncs between devices
/// which may not be on the same version (FR-NC-8).
/// * A hand-picked set is a statement about *kinds*: "the tone work, not
/// the colour". An operation whose kind is unknown is not one of the
/// kinds that were picked, so it stays behind rather than being smuggled
/// in under a selection that never named it.
carries_unclassified: bool,
}
impl Default for Scope {
fn default() -> Self {
Self::adjustments()
}
}
impl Scope {
/// Colour and tone and everything else the photograph is made of, but not
/// its shape. The default.
pub fn adjustments() -> Self {
Self {
bits: Self::all_bits() & !Self::bit(Attribute::Geometry),
carries_unclassified: true,
}
}
/// The whole edit, framing included.
pub fn everything() -> Self {
Self {
bits: Self::all_bits(),
carries_unclassified: true,
}
}
/// Exactly these kinds and nothing else.
///
/// See `carries_unclassified`: a hand-picked set deliberately leaves
/// behind operations this build cannot classify.
pub fn of(attributes: impl IntoIterator<Item = Attribute>) -> Self {
let bits = attributes.into_iter().fold(0, |acc, a| acc | Self::bit(a));
Self {
bits,
carries_unclassified: false,
}
}
/// Whether this scope reaches the named operation.
///
/// Takes a `&str` rather than an [`OpId`] because the caller may be
@@ -68,11 +148,63 @@ impl Scope {
/// offer — the sidecar path amends a parameter map without ever building
/// a graph.
pub fn covers(self, op: &str) -> bool {
match self {
Self::Everything => true,
Self::Adjustments => op != crate::framing::ID.0,
match attributes_of(op) {
Some(attributes) => attributes.iter().any(|a| self.has(*a)),
None => self.carries_unclassified,
}
}
/// Whether this scope names a kind.
pub fn has(self, attribute: Attribute) -> bool {
self.bits & Self::bit(attribute) != 0
}
/// The kinds this scope names, in declaration order.
pub fn attributes(self) -> impl Iterator<Item = Attribute> {
Attribute::ALL.into_iter().filter(move |a| self.has(*a))
}
/// Whether this scope names nothing at all.
///
/// A scope that reaches nothing is a legitimate thing to hold — it is what
/// an interface has while the photographer is still un-ticking boxes — and
/// applying it is a no-op rather than an error.
pub fn is_empty(self) -> bool {
self.bits == 0
}
fn bit(attribute: Attribute) -> u8 {
1 << Attribute::ALL
.iter()
.position(|a| *a == attribute)
.expect("every attribute is in ALL") as u8
}
fn all_bits() -> u8 {
Attribute::ALL.iter().fold(0, |acc, a| acc | Self::bit(*a))
}
}
/// The attributes an operation declares, without building a graph.
///
/// The batch path needs this: applying to forty images amends forty parameter
/// maps and never instantiates the chain (see [`Preset::amend`]), so asking a
/// graph what an operation is about would mean building one for the sole
/// purpose of reading a constant off it.
///
/// Built once from the default chain, which is where the declarations already
/// live — a second table listing operations by hand would be a second place to
/// forget an entry, and this module names no operation (see the module note).
fn attributes_of(op: &str) -> Option<&'static [Attribute]> {
static TABLE: std::sync::LazyLock<BTreeMap<String, Vec<Attribute>>> =
std::sync::LazyLock::new(|| {
EditGraph::default_chain()
.capabilities()
.into_iter()
.map(|cap| (cap.id.0.to_string(), cap.attributes))
.collect()
});
TABLE.get(op).map(Vec::as_slice)
}
/// A set of non-default parameter values, ready to apply elsewhere.
@@ -91,7 +223,7 @@ impl Preset {
/// 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
/// 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.
@@ -146,7 +278,7 @@ impl Preset {
pub fn touches_framing(&self) -> bool {
self.params
.keys()
.any(|(op, _)| !Scope::Adjustments.covers(op))
.any(|(op, _)| attributes_of(op).is_some_and(|a| a.contains(&Attribute::Geometry)))
}
/// How many operations this preset touches, in scope.
@@ -632,7 +764,7 @@ mod tests {
};
target.set_crop(target_crop);
preset.apply(&mut target, Scope::Adjustments);
preset.apply(&mut target, Scope::adjustments());
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
@@ -656,7 +788,7 @@ mod tests {
fn pasting_everything_carries_the_composition_too() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Everything);
preset.apply(&mut target, Scope::everything());
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
assert!(
@@ -677,7 +809,7 @@ mod tests {
target.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
target.set_param(saturation::ID, saturation::SATURATION, -50.0);
neutral.apply(&mut target, Scope::Adjustments);
neutral.apply(&mut target, Scope::adjustments());
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!(
@@ -696,7 +828,7 @@ mod tests {
let mut target = EditGraph::default_chain();
target.set_param(framing::ID, framing::ANGLE, 3.5);
neutral.apply(&mut target, Scope::Adjustments);
neutral.apply(&mut target, Scope::adjustments());
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(3.5));
}
@@ -714,7 +846,7 @@ mod tests {
height: 0.25,
});
preset.apply(&mut target, Scope::Everything);
preset.apply(&mut target, Scope::everything());
assert!(
target.framing().is_zoomed(),
"the paste threw away the viewport: {:?}",
@@ -731,7 +863,7 @@ mod tests {
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
preset.apply(&mut sideways, Scope::Everything);
preset.apply(&mut sideways, Scope::everything());
assert_eq!(
sideways.framing().baseline(),
@@ -748,7 +880,7 @@ mod tests {
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);
Preset::from_params(params).apply(&mut target, Scope::adjustments());
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(1.25));
}
@@ -759,7 +891,7 @@ mod tests {
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);
Preset::from_params(params).apply(&mut target, Scope::adjustments());
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
}
@@ -775,7 +907,7 @@ mod tests {
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);
preset.amend(&mut target, Scope::adjustments());
assert_eq!(
target.get(&("exposure".to_string(), "exposure".to_string())),
@@ -799,7 +931,7 @@ mod tests {
let mut target = BTreeMap::new();
target.insert(("framing".to_string(), "crop_w".to_string()), 0.3);
preset.amend(&mut target, Scope::Everything);
preset.amend(&mut target, Scope::everything());
assert_eq!(
target.get(&("framing".to_string(), "crop_w".to_string())),
Some(&0.5)
@@ -814,7 +946,7 @@ mod tests {
// amended map back through a graph and comparing every parameter.
let preset = Preset::capture(&edited());
for scope in [Scope::Adjustments, Scope::Everything] {
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);
@@ -825,7 +957,7 @@ mod tests {
preset.amend(&mut target_map, scope);
let mut rebuilt = EditGraph::default_chain();
Preset::from_params(target_map).apply(&mut rebuilt, Scope::Everything);
Preset::from_params(target_map).apply(&mut rebuilt, Scope::everything());
for cap in target_graph.capabilities() {
for p in &cap.params {
@@ -850,7 +982,7 @@ mod tests {
let preset = Preset::capture(&source);
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Everything);
preset.apply(&mut target, Scope::everything());
for cap in source.capabilities() {
for p in &cap.params {
@@ -869,8 +1001,8 @@ mod tests {
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);
assert_eq!(preset.op_count(Scope::everything()), 3);
assert_eq!(preset.op_count(Scope::adjustments()), 2);
}
#[test]
@@ -883,7 +1015,7 @@ mod tests {
.capabilities()
.iter()
.map(|c| c.id.0)
.filter(|id| !Scope::Adjustments.covers(id))
.filter(|id| !Scope::adjustments().covers(id))
.collect();
assert_eq!(excluded, vec![framing::ID.0]);
}
@@ -1054,7 +1186,7 @@ mod tests {
let preset = stored.get("Warm portrait").unwrap();
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
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));
@@ -1070,7 +1202,7 @@ mod tests {
params.insert(("framing".into(), "angle".into()), 5.0);
params.insert(("exposure".into(), "exposure".into()), -1.0);
preset.amend(&mut params, Scope::Adjustments);
preset.amend(&mut params, Scope::adjustments());
assert_eq!(
params.get(&("exposure".into(), "exposure".into())),
@@ -1079,4 +1211,114 @@ mod tests {
// Out of scope, so the target's own crop is untouched.
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
}
// -----------------------------------------------------------------------
// Partial scope
// -----------------------------------------------------------------------
#[test]
fn a_hand_picked_scope_carries_only_the_kinds_it_names() {
let preset = Preset::capture(&edited());
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::of([Attribute::Tone]));
// Tone was picked, so the exposure travelled.
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(0.75),
"the tone work should have travelled"
);
// Colour was not, so the white balance stayed behind.
assert_eq!(
target.param(white_balance::ID, white_balance::TEMPERATURE),
Some(0.0),
"the colour work should have stayed behind"
);
}
#[test]
fn an_operation_with_two_attributes_travels_with_either_of_them() {
// `tone_curve` declares tone *and* colour. Requiring both would make
// the curve unreachable unless both were ticked, which is not what
// "include the tone work" means to the person ticking it.
let curve = "tone_curve";
assert!(Scope::of([Attribute::Tone]).covers(curve));
assert!(Scope::of([Attribute::Colour]).covers(curve));
assert!(!Scope::of([Attribute::Detail]).covers(curve));
}
#[test]
fn geometry_is_the_only_thing_the_default_scope_leaves_out() {
let default_scope = Scope::adjustments();
let excluded: Vec<Attribute> = Attribute::ALL
.into_iter()
.filter(|a| !default_scope.has(*a))
.collect();
assert_eq!(excluded, vec![Attribute::Geometry]);
assert!(Scope::everything().has(Attribute::Geometry));
}
#[test]
fn a_whole_edit_scope_carries_an_operation_this_build_cannot_classify() {
// The version-skew case, and the reason `carries_unclassified` is a
// field rather than a rule: a preset made on a newer device still
// applies on an older one (FR-NC-8).
assert!(Scope::adjustments().covers("an_operation_from_the_future"));
assert!(Scope::everything().covers("an_operation_from_the_future"));
}
#[test]
fn a_hand_picked_scope_leaves_an_unclassifiable_operation_behind() {
// "The tone work, not the colour" is a statement about kinds, and an
// operation whose kind is unknown is not one of the kinds picked. It
// must not be smuggled in under a selection that never named it.
let picked = Scope::of([Attribute::Tone, Attribute::Colour]);
assert!(!picked.covers("an_operation_from_the_future"));
}
#[test]
fn a_scope_naming_nothing_moves_nothing_and_destroys_nothing() {
// What an interface holds while the photographer is still un-ticking
// boxes. Applying it must be a no-op rather than a reset.
let empty = Scope::of([]);
assert!(empty.is_empty());
let mut target = EditGraph::default_chain();
target.set_param(exposure::ID, exposure::EXPOSURE, -1.25);
Preset::capture(&edited()).apply(&mut target, empty);
assert_eq!(
target.param(exposure::ID, exposure::EXPOSURE),
Some(-1.25),
"an empty scope cleared the target"
);
}
#[test]
fn an_empty_scope_amends_a_map_without_touching_it() {
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
params.insert(("exposure".into(), "exposure".into()), -1.25);
let before = params.clone();
Preset::capture(&edited()).amend(&mut params, Scope::of([]));
assert_eq!(params, before);
}
#[test]
fn a_scope_lists_the_kinds_it_names_in_declaration_order() {
// Declaration order is roughly the order a photographer works in, and
// an interface drawing chips from this should not have to sort them.
let scope = Scope::of([Attribute::Detail, Attribute::Tone]);
assert_eq!(
scope.attributes().collect::<Vec<_>>(),
vec![Attribute::Tone, Attribute::Detail]
);
}
#[test]
fn an_attribute_name_survives_the_round_trip_it_is_stored_through() {
// These strings go into settings and come back. A change to one would
// silently reset a photographer's choice of what a paste carries.
for attribute in Attribute::ALL {
assert_eq!(Attribute::from_name(attribute.name()), Some(attribute));
}
}
}