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
+17
View File
@@ -618,6 +618,23 @@ impl Attribute {
})
}
/// The name used in `ops/<id>.yaml`, and the inverse of [`Self::from_name`].
///
/// A stable identifier rather than a label: it is written into settings
/// and read back, so changing one of these strings would silently reset a
/// photographer's choice of what a paste carries. The *displayed* name is
/// [`Self::label`], which is localised and free to change.
pub fn name(self) -> &'static str {
match self {
Self::Tone => "tone",
Self::Colour => "colour",
Self::Detail => "detail",
Self::Optics => "optics",
Self::Geometry => "geometry",
Self::Effect => "effect",
}
}
/// Parse the name used in `ops/<id>.yaml`.
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
+1 -1
View File
@@ -407,7 +407,7 @@ impl EditGraph {
// At full scope. `Scope` is a question about what a paste carries
// *between* photographs; this is one photograph's own edit being put
// back, so there is nothing to leave behind.
params.apply(self, Scope::Everything);
params.apply(self, Scope::everything());
self.masks = Arc::clone(masks);
self.spots = spots.clone();
+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));
}
}
}
+23
View File
@@ -203,7 +203,30 @@ pub struct DevelopSettings {
/// A device preference rather than a per-paste prompt — a photographer who
/// works one way works that way every time, and a dialogue on every paste
/// would be a question with the same answer forty times over.
///
/// **Superseded by [`Self::copy_attributes`]**, and kept because it is in
/// files already written: it is what a library saved by an older build
/// says, and the only thing it can say. Read once, to seed the richer
/// setting the first time a newer build opens an older config — see
/// `copy_attributes`.
pub copy_includes_framing: bool,
/// Which kinds of edit a copy, paste or preset carries, by attribute name
/// (`tone`, `colour`, `detail`, `optics`, `geometry`, `effect`).
///
/// Names rather than an enum because this crate sits *below* the pipeline
/// that defines those kinds and cannot see `dr_pipeline::Attribute`
/// without inverting the dependency. The pipeline parses them on the way
/// in and ignores a name it does not know, which is the same tolerance the
/// sidecar shows an unrecognised operation.
///
/// `None` means "never chosen on this device", which is *not* the same as
/// an empty list — that is a photographer who has un-ticked everything,
/// and their choice survives a restart like any other. `None` is what
/// makes the migration off `copy_includes_framing` possible: the reader
/// derives the set from the old boolean exactly once, and every later read
/// finds a real answer here.
pub copy_attributes: Option<Vec<String>>,
}
// ---------------------------------------------------------------------------
+28 -28
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -2051,6 +2051,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
}));
// TRACES: FR-DEV-6
// The scope chips, shared by the preset sheet and the settings page.
// Wired once and rendered once: both surfaces draw one set, because
// "what does a paste carry" is one question about how this
// photographer works rather than one per place it is asked.
presets::wire_scope(&window, settings.clone(), clipboard.clone());
presets::render_scope(&window, &settings);
presets::render(&window, &clipboard, &settings);
}
+178 -25
View File
@@ -40,7 +40,7 @@ use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::preset_store::PresetStore;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow, ScopeKind};
/// Where the develop view's current edit is stored.
///
@@ -142,16 +142,125 @@ pub fn describe(preset: &Preset, scope: Scope) -> String {
/// The scope a paste should use, from the user's preference.
///
/// One function rather than the boolean read at each call site, so "the
/// setting is off by default and means framing stays behind" is stated once.
/// One function rather than the setting read at each call site, so what the
/// stored names mean — including what their absence means — is stated once.
///
/// # The migration lives here
///
/// `copy_attributes` is `None` on a device that has only ever run a build with
/// the old boolean, and the answer is derived from that boolean exactly once:
/// a photographer who had asked for the crop to travel keeps getting the crop.
/// Writing the derived set back is the *caller's* business — see
/// [`settings_ui`] — because a read has no business having a side effect on
/// the file it read from.
///
/// A name this build does not recognise is dropped rather than refused, the
/// same tolerance the sidecar shows an unknown operation: a config written by
/// a newer build must not stop an older one from pasting.
pub fn scope_for(settings: &dr_types::Settings) -> Scope {
if settings.develop.copy_includes_framing {
Scope::Everything
} else {
Scope::Adjustments
let Some(names) = settings.develop.copy_attributes.as_ref() else {
return if settings.develop.copy_includes_framing {
Scope::everything()
} else {
Scope::adjustments()
};
};
Scope::of(names.iter().filter_map(|n| {
let attribute = dr_pipeline::Attribute::from_name(n);
if attribute.is_none() {
log::debug!("preset scope: ignoring unknown attribute {n:?}");
}
attribute
}))
}
/// The names to store for a scope, for the setting [`scope_for`] reads back.
pub fn names_for(scope: Scope) -> Vec<String> {
scope.attributes().map(|a| a.name().to_string()).collect()
}
/// The scope chips, in the order the pipeline declares its attributes.
///
/// Declaration order is roughly the order a photographer works in, which is
/// the order [`Attribute::ALL`] is written for. Nothing here chooses a
/// sequence of its own, and nothing here names an operation (FR-DEV-3c) — the
/// six kinds come from the pipeline and the labels from their descriptors.
pub fn scope_kinds(scope: Scope) -> Vec<ScopeKind> {
dr_pipeline::Attribute::ALL
.into_iter()
.map(|attribute| ScopeKind {
name: attribute.name().into(),
label: label_for(attribute).into(),
on: scope.has(attribute),
})
.collect()
}
/// The word for an attribute.
///
/// Written here rather than taken from `Attribute::label`, which returns a
/// localisation key (`attr.tone`) for a catalogue that does not exist yet.
/// When it does, this becomes a lookup and the strings leave this file — which
/// is why they are gathered in one function rather than spread through the
/// panel.
fn label_for(attribute: dr_pipeline::Attribute) -> &'static str {
use dr_pipeline::Attribute;
match attribute {
Attribute::Tone => "Tone",
Attribute::Colour => "Colour",
Attribute::Detail => "Detail",
Attribute::Optics => "Optics",
Attribute::Geometry => "Geometry",
Attribute::Effect => "Effect",
}
}
/// Push the current scope onto the window, wherever it is drawn.
pub fn render_scope(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
let scope = scope_for(&settings.snapshot());
let kinds: Vec<ScopeKind> = scope_kinds(scope);
window.set_copy_scope_kinds(slint::ModelRc::new(slint::VecModel::from(kinds)));
window.set_copy_scope_empty(scope.is_empty());
}
/// Wire the scope chips, which every surface that draws them shares.
pub fn wire_scope(
window: &AppWindow,
settings: Rc<settings_ui::SettingsController>,
clipboard: Rc<Clipboard>,
) {
let weak = window.as_weak();
window.on_copy_scope_toggled(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(attribute) = dr_pipeline::Attribute::from_name(&name) else {
return;
};
// Read, flip, write the whole set. The stored value is a list of names
// rather than a bitfield precisely so that this is the only place that
// has to know how the two representations line up.
let scope = scope_for(&settings.snapshot());
let flipped: Vec<dr_pipeline::Attribute> = dr_pipeline::Attribute::ALL
.into_iter()
.filter(|a| {
if *a == attribute {
!scope.has(*a)
} else {
scope.has(*a)
}
})
.collect();
settings.edit(|s| s.develop.copy_attributes = Some(names_for(Scope::of(flipped))));
render_scope(&w, &settings);
// The clipboard's summary counts operations *in scope*, so it changes
// whenever this does — and a summary describing a scope the buttons
// are no longer using is worse than no summary.
render(&w, &clipboard, &settings);
});
}
// ---------------------------------------------------------------------------
// Local sidecars
// ---------------------------------------------------------------------------
@@ -312,7 +421,7 @@ pub fn save_open_edit(
// `Everything`: this is the image's *own* edit being written back,
// not a paste onto someone else's. Excluding framing here would
// make a crop the one adjustment that never survived a restart.
if let Err(e) = save_local(path, &preset, Scope::Everything, Some(&masks)) {
if let Err(e) = save_local(path, &preset, Scope::everything(), Some(&masks)) {
log::warn!("saving {}: {e}", path.display());
}
}
@@ -323,7 +432,7 @@ pub fn save_open_edit(
version_uuid: version_uuid.clone(),
amendment: library::Amendment::Settings {
preset,
scope: Scope::Everything,
scope: Scope::everything(),
// The image's own edit, so the whole of it: `Everything`
// already says framing travels, and the local adjustments
// have to travel for the same reason. Without this the
@@ -431,7 +540,9 @@ pub fn render(
// Only worth saying when it is actually true of *this* copy: a clipboard
// holding no crop loses nothing to the setting, and saying so anyway would
// train the user to ignore the line.
window.set_settings_framing_withheld(clipboard.holds_framing() && scope == Scope::Adjustments);
window.set_settings_framing_withheld(
clipboard.holds_framing() && !scope.has(dr_pipeline::Attribute::Geometry),
);
}
/// The develop view's shared state, as this module needs it.
@@ -731,7 +842,7 @@ pub fn wire_named(
let summary = session
.borrow()
.as_ref()
.map(|s| describe(&s.copy_settings(), Scope::Everything))
.map(|s| describe(&s.copy_settings(), Scope::everything()))
.unwrap_or_default();
w.set_preset_capture_summary(summary.into());
});
@@ -908,7 +1019,7 @@ mod tests {
save_local(
&image,
&Preset::capture(&graph),
Scope::Everything,
Scope::everything(),
Some(graph.masks()),
)
.unwrap();
@@ -952,7 +1063,13 @@ mod tests {
let dir = tempdir("round-trip");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let sidecar = load_local(&image).expect("a sidecar was written");
let mut restored = EditGraph::default_chain();
@@ -979,8 +1096,20 @@ mod tests {
let dir = tempdir("one-version");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
}
@@ -992,13 +1121,25 @@ mod tests {
let dir = tempdir("revision");
let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let first = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let second = load_local(&image)
.unwrap()
.default_version()
@@ -1026,7 +1167,13 @@ mod tests {
});
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None,
)
.unwrap();
let back = load_local(&image).unwrap();
let v = back.default_version().unwrap();
@@ -1043,7 +1190,13 @@ mod tests {
let image = dir.join("a.CR2");
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).is_err());
assert!(save_local(
&image,
&Preset::capture(&edited()),
Scope::everything(),
None
)
.is_err());
assert_eq!(
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
"drsc 99\n",
@@ -1057,7 +1210,7 @@ mod tests {
save_local(
&dir.join("a.CR2"),
&Preset::capture(&edited()),
Scope::Everything,
Scope::everything(),
None,
)
.unwrap();
@@ -1083,12 +1236,12 @@ mod tests {
let mut settings = dr_types::Settings::default();
assert_eq!(
scope_for(&settings),
Scope::Adjustments,
Scope::adjustments(),
"a fresh install must not carry crops between photographs"
);
settings.develop.copy_includes_framing = true;
assert_eq!(scope_for(&settings), Scope::Everything);
assert_eq!(scope_for(&settings), Scope::everything());
}
#[test]
@@ -1103,7 +1256,7 @@ mod tests {
clipboard.put(Preset::capture(&g));
// Three parameters, two operations.
assert_eq!(clipboard.describe(Scope::Adjustments), "2 adjustments");
assert_eq!(clipboard.describe(Scope::adjustments()), "2 adjustments");
}
#[test]
@@ -1115,7 +1268,7 @@ mod tests {
clipboard.put(Preset::capture(&EditGraph::default_chain()));
assert!(clipboard.is_armed());
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
assert_eq!(clipboard.describe(Scope::adjustments()), "Neutral");
}
// -----------------------------------------------------------------------
@@ -1148,7 +1301,7 @@ mod tests {
let preset = presets.get("Warm").expect("stored");
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
preset.apply(&mut target, Scope::adjustments());
assert_eq!(
target.param(
dr_pipeline::ops::exposure::ID,
+6 -16
View File
@@ -111,7 +111,12 @@ impl SettingsController {
/// accidentally write back a stale copy of the fields it was not editing —
/// with a save on every keystroke, two controls holding their own snapshots
/// would overwrite each other.
fn edit(&self, f: impl FnOnce(&mut Settings)) {
///
/// `pub(crate)` rather than private because the settings page is no longer
/// the only surface that writes a preference: the scope chips are drawn
/// here *and* in the preset sheet, and both change one stored set. The
/// closure is what makes that safe, which is why the shape stays.
pub(crate) fn edit(&self, f: impl FnOnce(&mut Settings)) {
{
let mut settings = self.settings.borrow_mut();
f(&mut settings);
@@ -145,7 +150,6 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
window.set_settings_thumbnail_budget(budget::label(s.cache.thumbnail_budget_bytes).into());
window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none());
window.set_settings_keep_opened(s.cache.keep_opened_originals);
window.set_settings_copy_includes_framing(s.develop.copy_includes_framing);
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into());
// --- library -------------------------------------------------------
@@ -627,20 +631,6 @@ pub fn wire<F, G>(
});
}
// TRACES: FR-DEV-6
// Whether a copied edit carries the crop. Off by default — see
// `DevelopSettings::copy_includes_framing` for why that is the safe
// direction rather than merely the conservative one.
{
let weak = window.as_weak();
let ctl = controller.clone();
window.on_settings_copy_includes_framing_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
ctl.edit(|s| s.develop.copy_includes_framing = on);
render(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = controller.clone();
+18 -7
View File
@@ -10,7 +10,7 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { PresetSheet } from "presets.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
@@ -669,6 +669,17 @@ export component AppWindow inherits Window {
/// be recomputing a string nobody is looking at.
callback presets-opened();
/// TRACES: FR-DEV-6
/// Which kinds of edit a copy, paste or preset carries.
///
/// One set for all three, on the window because all three read it: the
/// clipboard is pasted from develop and from the grid, and a preset is
/// applied from both. A per-action set would be three answers to one
/// question about how this photographer works.
in property <[ScopeKind]> copy-scope-kinds;
in property <bool> copy-scope-empty: false;
callback copy-scope-toggled(string);
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
@@ -736,7 +747,6 @@ export component AppWindow inherits Window {
in property <string> settings-thumbnail-budget: "";
in property <bool> settings-thumbnail-unlimited: false;
in property <bool> settings-keep-opened: true;
in property <bool> settings-copy-includes-framing: false;
in property <string> settings-cache-usage: "";
callback settings-original-budget-changed(string);
@@ -744,7 +754,6 @@ export component AppWindow inherits Window {
callback settings-thumbnail-budget-changed(string);
callback settings-thumbnail-unlimited-toggled(bool);
callback settings-keep-opened-toggled(bool);
callback settings-copy-includes-framing-toggled(bool);
/// TRACES: FR-CAT-6
in property <[string]> settings-timeline-bar-labels;
in property <int> settings-timeline-bars-selected: 0;
@@ -1150,7 +1159,9 @@ in property <bool> panel-visible: true;
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
copy-includes-framing: root.settings-copy-includes-framing;
copy-scope-kinds: root.copy-scope-kinds;
copy-scope-empty: root.copy-scope-empty;
copy-scope-toggled(name) => { root.copy-scope-toggled(name); }
timeline-bar-labels: root.settings-timeline-bar-labels;
timeline-bars-selected: root.settings-timeline-bars-selected;
timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); }
@@ -1165,9 +1176,6 @@ in property <bool> panel-visible: true;
root.settings-thumbnail-unlimited-toggled(on);
}
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
copy-includes-framing-toggled(on) => {
root.settings-copy-includes-framing-toggled(on);
}
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
@@ -2462,6 +2470,9 @@ in property <bool> panel-visible: true;
apply-count: root.preset-apply-count;
capture-summary: root.preset-capture-summary;
name-error: root.preset-name-error;
scope-kinds: root.copy-scope-kinds;
scope-empty: root.copy-scope-empty;
scope-toggled(name) => { root.copy-scope-toggled(name); }
save(name) => { root.save-preset(name); }
apply(name) => { root.apply-preset(name); }
rename(from, to) => { root.rename-preset(from, to); }
+92 -1
View File
@@ -1,5 +1,61 @@
import { Theme } from "theme.slint";
import { Button, Field, Caption } from "widgets.slint";
import { Button, Field, Caption, PanelHeading } from "widgets.slint";
import { ChoiceChip } from "controls.slint";
// TRACES: FR-DEV-6
// One kind of edit, and whether a copy carries it.
//
// The `name` is the stable identifier the pipeline and the settings file both
// use (`tone`, `colour`, …); `label` is what a person reads. Actions are sent
// by name rather than by index because this model is rebuilt from Rust
// whenever the set changes, and an index would name whatever moved into that
// slot.
export struct ScopeKind {
name: string,
label: string,
on: bool,
}
// TRACES: FR-DEV-6 | FR-UI-2
// Which kinds of edit travel: a multi-select block of chips that wraps.
//
// Laid out by index arithmetic rather than by a layout, for the reason
// `ChipGrid` sets out at length: Slint has no wrapping layout, a
// `HorizontalLayout` of chips reports its minimum width as the sum of all of
// them, and a `for` inside a `GridLayout` compiles and then fails at run time.
// This is `ChipGrid` with the single selection replaced by a flag per chip —
// close enough to want sharing, different enough in its model that sharing
// would mean one component doing two jobs badly.
//
// **Not placed in the develop column.** Six chips there would set the width of
// the whole sidebar, which is the bug `ChipGrid`'s comment records; this lives
// on the full-width surfaces — the preset sheet and the settings page — and
// the develop panel says in words what these say in chips.
export component ScopeChips inherits Rectangle {
in property <[ScopeKind]> kinds;
in property <bool> enabled: true;
in property <int> columns: 3;
in property <length> chip-width: 88px;
callback toggled(string);
property <int> rows: max(1, ceil(root.kinds.length / max(1, root.columns)));
property <length> pitch: Theme.control-height + Theme.gap-sm;
min-width: root.columns * root.chip-width + (root.columns - 1) * Theme.gap-sm;
height: root.rows * root.pitch - Theme.gap-sm;
for kind[i] in root.kinds: ChoiceChip {
x: mod(i, root.columns) * (root.chip-width + Theme.gap-sm);
y: floor(i / root.columns) * root.pitch;
width: root.chip-width;
height: Theme.control-height;
label: kind.label;
selected: kind.on;
enabled: root.enabled;
clicked => { root.toggled(kind.name); }
}
}
// TRACES: FR-DEV-6
// The named preset sheet: save the edit in hand, and apply a saved one.
@@ -46,6 +102,14 @@ export component PresetSheet inherits Rectangle {
/// user has started correcting.
callback name-edited(string);
/// TRACES: FR-DEV-6
/// Which kinds of edit an apply carries, and the toggle that changes it.
in property <[ScopeKind]> scope-kinds;
/// Whether the selection names nothing, so the sheet can say that applying
/// would do nothing rather than letting it silently not.
in property <bool> scope-empty: false;
callback scope-toggled(string);
background: #000000CC;
// Swallows the taps that miss the card, and closes. First, so the card's
@@ -201,6 +265,33 @@ export component PresetSheet inherits Rectangle {
Rectangle { height: 1px; background: Theme.rule; }
// TRACES: FR-DEV-6
// What an apply carries. At the bottom, because it qualifies every
// action above it rather than being one of them — and because the
// answer is remembered, so most visits never touch it.
//
// An apply-time control rather than a save-time one: a preset is
// captured whole (see `Preset::capture`), so changing this after
// saving still takes effect, where a preset that had already
// discarded its colour could never grow it back.
VerticalLayout {
spacing: Theme.gap-sm;
PanelHeading { text: "CARRIES"; }
ScopeChips {
kinds: root.scope-kinds;
toggled(name) => { root.scope-toggled(name); }
}
if root.scope-empty: Caption {
text: "Nothing selected — applying would change nothing.";
warn: true;
}
}
Rectangle { height: 1px; background: Theme.rule; }
Button {
text: "Done";
clicked => { root.dismiss(); }
+39 -17
View File
@@ -1,6 +1,7 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
import { ScopeChips, ScopeKind } from "presets.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
//
@@ -98,8 +99,11 @@ export component SettingsPage inherits Rectangle {
in property <bool> thumbnail-unlimited: false;
in property <bool> keep-opened: true;
/// TRACES: FR-DEV-6
/// Whether copying settings carries the crop and rotation with it.
in property <bool> copy-includes-framing: false;
/// Which kinds of edit a copy or preset carries. Supersedes the boolean
/// above, which named only the one kind anybody wanted to exclude.
in property <[ScopeKind]> copy-scope-kinds;
in property <bool> copy-scope-empty: false;
callback copy-scope-toggled(string);
/// What the cache currently holds. Empty hides the line.
in property <string> cache-usage;
@@ -141,7 +145,6 @@ export component SettingsPage inherits Rectangle {
callback thumbnail-budget-changed(string);
callback thumbnail-unlimited-toggled(bool);
callback keep-opened-toggled(bool);
callback copy-includes-framing-toggled(bool);
// --- library -------------------------------------------------------
/// TRACES: FR-CAT-6
@@ -551,20 +554,39 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "DEVELOP"; }
Check {
label: "Copy crop and rotation with settings";
// Says what the *off* state does, because off is
// the default and is the behaviour that needs
// explaining: a user who has never opened this
// page should still be able to predict what a
// paste did to their crops.
hint: "Off, pasting settings changes only colour and "
+ "tone, and each photograph keeps its own "
+ "composition. On, the crop, straightening, "
+ "rotation and flips travel too — which "
+ "re-frames every image pasted onto.";
checked: root.copy-includes-framing;
toggled(on) => { root.copy-includes-framing-toggled(on); }
// TRACES: FR-DEV-6
// Which kinds of edit a copy, a paste or a preset
// carries.
//
// This replaced a single "copy crop and rotation"
// checkbox. That question was the right one to ask
// first — geometry is the kind whose accidental
// travel destroys work — but it was the only question
// a boolean could ask, and "match the colour but not
// the sharpening" had no way to be said.
//
// Chips rather than six checkboxes: these are one
// question with six answers, and a column of ticks
// reads as six unrelated preferences.
VerticalLayout {
spacing: 4px;
Label { text: "Copying carries"; }
ScopeChips {
kinds: root.copy-scope-kinds;
columns: 6;
toggled(name) => { root.copy-scope-toggled(name); }
}
Caption {
text: root.copy-scope-empty
? "Nothing selected — a paste would change nothing."
: "Geometry is off by default: a crop is a decision "
+ "about one photograph's composition, and carrying "
+ "it re-frames every image pasted onto.";
warn: root.copy-scope-empty;
}
}
}
}