Files
DarkRoom/ui/dr-ui/src/presets.rs
T
dtourolleandClaude Opus 5 93efdf27a6 Keep the film stock when an edit is saved
`Version::update` is the write path an automatic save goes through. It copied
the parameters and the masks and said nothing about the film, so a photograph
developed on a stock was written back without it and opened the next time
without its emulsion. Nothing reported a failure — the line was simply not
there.

It is the third of the three routines that captured "the edit" and the only
one that got it wrong, which is the argument for not having three. All of them
now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot
disagree about what an edit consists of, and the next part of one cannot be
lost by anybody writing a line too few.

Two tests, both of which fail without the fix: the field survives `update`,
and the stock survives the round trip through the file. `apply` returns the
`FilmRebake` it always implicitly owed, so `apply_version` now reads the debt
off the call rather than off `version.film` — and pays it in both directions,
since a version with no film has to clear the adjust pass too or it keeps
textures bound that nothing will sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:53:00 +02:00

808 lines
30 KiB
Rust

//! TRACES: FR-DEV-6
//! Copying develop settings from one photograph to others.
//!
//! # The three pieces
//!
//! * A [`Clipboard`] — one [`Preset`] held for the life of the window. Not
//! the system clipboard: these are typed values addressed by `op.param`,
//! and putting them on a text clipboard would mean anything that happened
//! to be copied afterwards silently disarmed the paste.
//! * An [`OpenImage`] — where the develop view's edit is *stored*, which is
//! what a copy is taken from and what a paste has to be written back to.
//! * The batch, in [`paste_to_selection`], which never opens an image at all.
//!
//! # Why a paste can reach images that are not open
//!
//! Applying settings to forty frames by opening forty develop sessions would
//! mean forty downloads of a whole RAW file and forty demosaics, to change
//! some numbers. The edit is *data* — [`Preset::amend`] operates on the
//! parameter map a sidecar already stores — so the batch is a read-modify-write
//! per file and never touches a GPU. That is what makes it usable on a phone.
//!
//! The cost is that a batch paste is only visible once the target is opened
//! and its sidecar read, which is the load path this module also provides.
//!
//! # Two stores, because there are two ways an image is opened
//!
//! An image from the library grid lives on the server and its sidecar goes
//! beside it, through the same writer a rating uses. An image named on the
//! command line is a local file with no library behind it, and its sidecar is
//! written beside it on disk. Both are the same document in the same format —
//! only the transport differs, which is why [`Stored`] is an enum over where
//! rather than two notions of what.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use dr_pipeline::{Preset, Scope, Sidecar};
use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
/// Where the develop view's current edit is stored.
///
/// `None` is not an error state: an image that failed to decode, or one opened
/// before a library was chosen, has nowhere to persist to and simply does not.
/// Copy still works from it — the graph is in memory either way — and it is
/// only the *saving* that has no destination.
#[derive(Debug, Clone, Default)]
pub enum Stored {
#[default]
Nowhere,
/// A file on this device, named on the command line.
Local(PathBuf),
/// An image in the library. The uuid comes from the catalog so that this
/// device and every other name the same version (FR-NC-8).
Remote { path: String, version_uuid: String },
}
/// Which image the develop view is showing, and where its edit belongs.
pub type OpenImage = Rc<RefCell<Stored>>;
/// The settings clipboard.
///
/// Holds the preset at full scope; the [`Scope`] is applied at paste time from
/// the user's setting, so changing that setting after copying takes effect on
/// the next paste rather than requiring a fresh copy.
#[derive(Default)]
pub struct Clipboard {
preset: RefCell<Option<Preset>>,
}
impl Clipboard {
pub fn new() -> Rc<Self> {
Rc::new(Self::default())
}
/// Take a copy of a session's edit.
pub fn copy_from(&self, session: &DevelopSession) {
self.put(session.copy_settings());
}
/// Hold an already-captured preset.
pub fn put(&self, preset: Preset) {
*self.preset.borrow_mut() = Some(preset);
}
/// The held preset, if there is one.
pub fn peek(&self) -> Option<Preset> {
self.preset.borrow().clone()
}
/// Whether anything has been copied.
///
/// True even for a *neutral* copy. Copying an unedited photograph and
/// pasting it onto an edited one is a meaningful action — it clears the
/// target — so "has something been copied" is a different question from
/// "does the copy contain any values", and this is the first.
pub fn is_armed(&self) -> bool {
self.preset.borrow().is_some()
}
/// Whether the held preset carries framing that the current scope drops.
///
/// What the interface uses to explain a setting's effect on *this*
/// clipboard rather than in the abstract.
pub fn holds_framing(&self) -> bool {
self.preset
.borrow()
.as_ref()
.is_some_and(|p| p.touches_framing())
}
/// A short description of what would be pasted, for a button's label.
///
/// Counts operations rather than parameters: the colour mixer alone
/// declares thirty-six, and "36 settings" would say something about the
/// mixer's shape rather than about how much was copied.
pub fn describe(&self, scope: Scope) -> String {
let Some(preset) = self.preset.borrow().clone() else {
return String::new();
};
match preset.op_count(scope) {
0 => "Neutral".to_string(),
1 => "1 adjustment".to_string(),
n => format!("{n} adjustments"),
}
}
}
/// 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.
pub fn scope_for(settings: &dr_types::Settings) -> Scope {
if settings.develop.copy_includes_framing {
Scope::Everything
} else {
Scope::Adjustments
}
}
// ---------------------------------------------------------------------------
// Local sidecars
// ---------------------------------------------------------------------------
/// The sidecar path for a local image — the file's own path with the
/// extension replaced.
///
/// The same rule [`crate::library::sidecar_path`] applies to remote paths, so
/// a folder synced between the two is read identically from either side.
pub fn local_sidecar_path(image: &Path) -> PathBuf {
image.with_extension(dr_pipeline::sidecar::EXTENSION)
}
/// Read a local sidecar, if there is one.
///
/// Absence is the common case and is not an error. An *unreadable* file yields
/// `None` too, and [`save_local`] independently refuses to overwrite one — so
/// a sidecar this build cannot parse costs the edit being shown, never the
/// edit being kept.
pub fn load_local(image: &Path) -> Option<Sidecar> {
let path = local_sidecar_path(image);
let text = std::fs::read_to_string(&path).ok()?;
match Sidecar::parse(&text) {
Ok(s) => Some(s),
Err(e) => {
log::warn!("sidecar at {} is unreadable ({e})", path.display());
None
}
}
}
/// Read-modify-write a local sidecar.
///
/// Read first for the same reason the remote writer does: the file may already
/// hold a rating, or an operation this build does not know about, and writing
/// a fresh document containing only the current edit would delete both.
/// `masks` carries the local adjustments when this is the image's own edit,
/// and is `None` for a paste, which must leave the target's masks alone — a
/// mask is drawn against one photograph and describes nothing on another. See
/// `library::Amendment::Settings` for the same distinction on the remote path.
pub fn save_local(
image: &Path,
preset: &Preset,
scope: Scope,
masks: Option<&dr_pipeline::mask::MaskStack>,
) -> Result<(), String> {
let path = local_sidecar_path(image);
// Distinguish "no sidecar yet" from "a sidecar this build cannot read".
// Only the second is a refusal — overwriting it would destroy an edit we
// merely failed to understand.
let existing = std::fs::read_to_string(&path).ok();
let mut sidecar = match existing.as_deref() {
None => Sidecar::new(),
Some(text) => Sidecar::parse(text).map_err(|e| {
format!(
"existing sidecar at {} is unreadable ({e}); not overwriting",
path.display()
)
})?,
};
// A local file has no catalog behind it to supply a version identity, so
// the file's own default version is used and one is created if absent.
// Deterministic rather than random: reopening the same photograph must
// amend the version it wrote last time, not accumulate one per save.
let uuid = sidecar
.default_version()
.map(|v| v.uuid.clone())
.unwrap_or_else(|| "local".to_string());
let mut version =
sidecar
.versions
.get(&uuid)
.cloned()
.unwrap_or_else(|| dr_pipeline::sidecar::Version {
uuid: uuid.clone(),
name: "Default".to_string(),
is_default: true,
revision: 0,
..Default::default()
});
preset.amend(&mut version.params, scope);
// Wholesale, not merged: this is the stack as it stands, so a layer the
// user deleted must leave the file too.
if let Some(masks) = masks {
version.masks = masks.clone();
}
version.revision = version.revision.saturating_add(1);
version.modified = now_secs();
sidecar.put(version);
let text = sidecar.to_text();
// Write and rename, so an interrupted save cannot truncate an edit that
// was already safely on disk.
let tmp = path.with_extension("drsc.tmp");
std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Saving and loading the open image
// ---------------------------------------------------------------------------
/// Persist the develop view's current edit to wherever it belongs.
///
/// Called when the image is about to stop being the open one — on leaving for
/// the library, on stepping to the next frame, and on closing the window —
/// rather than on every slider move. A save is a network round-trip on the
/// library path, and one per drag frame would put an upload inside the gesture
/// that NFR-P5 is about.
///
/// Silent on success and logged on failure, deliberately. There is no
/// acknowledgement worth interrupting a photographer for, and the failure that
/// matters — a sidecar this build could not parse — is refused by the writer
/// rather than resolved here.
pub fn save_open_edit(
window: &AppWindow,
stored: &Stored,
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
// Both taken in one borrow: they are one edit, and a mask stack captured
// from a session that had already moved on would be a different image's.
let Some((preset, masks, film)) = session.borrow().as_ref().map(|s| {
(
s.copy_settings(),
s.masks().clone(),
// TRACES: FR-DEV-3f
// Taken in the same borrow as the other two, for the reason the
// note above gives: they are one edit, and a stock read from a
// session that had already moved on would be a different
// photograph's.
s.film()
.map(|(stock, print)| dr_pipeline::sidecar::FilmRef {
stock: stock.to_string(),
print: print.then(|| {
dr_film::find(stock)
.and_then(dr_film::default_print)
.map(|p| p.stock.clone())
.unwrap_or_default()
}),
}),
)
}) else {
return;
};
match stored {
// Nothing to save to. An image that failed to decode, or one opened
// before a library was chosen.
Stored::Nowhere => {}
Stored::Local(path) => {
// `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)) {
log::warn!("saving {}: {e}", path.display());
}
}
Stored::Remote { path, version_uuid } => {
let write = library::SidecarWrite {
image_path: path.clone(),
version_uuid: version_uuid.clone(),
amendment: library::Amendment::Settings {
preset,
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
// sliders were written and every mask was dropped.
masks: Some(masks),
film: Some(film),
},
};
library_ui::start_sidecar_writes(window, library, vec![write]);
// TRACES: FR-CAT-9
// And bring the grid's cell up to date, so the photograph the user
// just finished is the photograph they see in the library.
//
// After the sidecar write is queued, never instead of it: the edit
// is the thing that must not be lost, and a render that failed
// must not take the save down with it.
refresh_thumbnail_for(window, path, session, library);
}
}
}
/// TRACES: FR-CAT-9
/// Re-render the grid's thumbnail from the edit that is being saved.
///
/// # When it is worth doing
///
/// Two cases, and the second is the one that is easy to miss.
///
/// An edit made in this sitting is the obvious one — `can-undo` is the app's
/// own record that something was changed, and it stays true for a change that
/// happened to end at neutral, which still needs the thumbnail putting back.
///
/// The other is an image opened with an edit already in its sidecar and left
/// untouched. Nothing changed, so there is nothing to *re-*render — but the
/// cached thumbnail came from the file's embedded preview and has never shown
/// that edit at all. `is_neutral` is what distinguishes it: a graph that does
/// something, against a thumbnail that shows none of it.
///
/// A neutral image nobody touched fails both and costs nothing, which is the
/// common case on a scroll through a library.
fn refresh_thumbnail_for(
window: &AppWindow,
path: &str,
session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>,
) {
let worth_it =
window.get_can_undo() || session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
if !worth_it {
return;
}
// The borrow is held across the renders, which is safe here and would not
// be if this were reachable from a Slint callback that also touches the
// session — it is not: the only caller is on the way out of the view.
let mut borrow = session.borrow_mut();
let Some(open) = borrow.as_mut() else {
return;
};
library_ui::refresh_thumbnail(window, library, path, |edge| open.render_thumbnail(edge));
}
/// Load a stored edit into the open session and refresh the panel.
///
/// Returns whether anything was applied, so the caller can skip a redraw for
/// the common case of a photograph that has never been edited.
pub fn apply_stored_edit(
window: &AppWindow,
sidecar: &Sidecar,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
) -> bool {
let Some(version) = sidecar.default_version() else {
return false;
};
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return false };
s.apply_version(version);
}
crate::sync_rows(window, rows, session);
true
}
// ---------------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------------
/// Push the clipboard's state onto the window.
///
/// One function rather than three `set_` calls at each site, because the three
/// properties have to agree: a summary describing a scope the button is not
/// using would be worse than no summary.
pub fn render(
window: &AppWindow,
clipboard: &Rc<Clipboard>,
settings: &Rc<settings_ui::SettingsController>,
) {
let scope = scope_for(&settings.snapshot());
window.set_settings_armed(clipboard.is_armed());
window.set_settings_summary(clipboard.describe(scope).into());
// 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);
}
/// The develop view's shared state, as this module needs it.
///
/// Bundled rather than passed one by one because these four always travel
/// together — a paste changes the graph, so it must rebuild the panel, redraw
/// the canvas and know where to save, and a call site holding three of the
/// four is a call site with a bug in it.
#[derive(Clone)]
pub struct Develop {
pub session: Rc<RefCell<Option<DevelopSession>>>,
pub rows: Rc<slint::VecModel<ParamRow>>,
pub redraw: Rc<dyn Fn(&AppWindow)>,
/// Where the open image's edit is stored.
pub open: OpenImage,
}
/// Wire copy, paste and batch-paste.
pub fn wire(
window: &AppWindow,
clipboard: Rc<Clipboard>,
develop: Develop,
settings: Rc<settings_ui::SettingsController>,
library: Rc<library_ui::LibraryController>,
collections: Rc<crate::collections_ui::CollectionsController>,
) {
let Develop {
session,
rows,
redraw,
open,
} = develop;
// --- copy ------------------------------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
window.on_copy_settings(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow().as_ref() {
clipboard.copy_from(s);
}
render(&w, &clipboard, &settings);
});
}
// --- paste onto the open image ---------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let session = session.clone();
let settings = settings.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
let library = library.clone();
window.on_paste_settings(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
};
let scope = scope_for(&settings.snapshot());
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The sliders are showing the values that just moved, so the panel
// has to be rebuilt — a paste is the one edit that changes many
// controls without any of them having been touched.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
// Saved immediately rather than on the way out. A paste is a
// deliberate, discrete action, unlike a drag, and the cost of one
// write is nothing beside the surprise of it not having stuck.
save_open_edit(&w, &open.borrow(), &session, &library);
});
}
// --- paste onto the selection ----------------------------------------
{
let weak = window.as_weak();
let clipboard = clipboard.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
window.on_paste_settings_to_selection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = clipboard.peek() else {
return;
};
let scope = scope_for(&settings.snapshot());
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
});
}
}
/// Seconds since the epoch, or zero if the clock is before it.
///
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
/// which resolves on `revision` first anyway (FR-NC-9), and refusing to save
/// because a clock is unset would be far worse.
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::EditGraph;
fn tempdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"dr-presets-test-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn edited() -> EditGraph {
use dr_pipeline::ops::exposure;
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
g
}
/// The bug this exists to prevent: a local adjustment that survived until
/// the session ended and then did not exist.
///
/// The sidecar format has stored masks since they were added, and
/// `Version::apply` restores them — but the save path wrote a `Preset`,
/// which is a parameter map, and a mask is not a parameter. So the sliders
/// came back and every mask was silently gone. Nothing reported it,
/// because nothing had failed.
#[test]
fn a_mask_survives_being_written_and_read_back() {
use dr_pipeline::mask::{MaskLayer, MaskSource};
let dir = tempdir("mask-roundtrip");
let image = dir.join("a.CR2");
let mut graph = edited();
let mut layer = MaskLayer::new(
"m1",
MaskSource::Radial {
centre: (0.4, 0.6),
radii: (0.2, 0.3),
angle: 0.0,
feather: 0.5,
},
);
layer.opacity = 0.75;
graph.masks_mut().push(layer);
assert_eq!(graph.masks().len(), 1, "the premise: the edit has a mask");
save_local(
&image,
&Preset::capture(&graph),
Scope::Everything,
Some(graph.masks()),
)
.unwrap();
// Read it back the way opening the photograph again does.
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
let sidecar = Sidecar::parse(&text).unwrap();
let version = sidecar.default_version().expect("a default version");
let mut reopened = EditGraph::default_chain();
version.apply(&mut reopened).expect_no_film();
assert_eq!(
reopened.masks().len(),
1,
"the mask must come back with the photograph"
);
let back = &reopened.masks().layers()[0];
assert_eq!(back.id, "m1");
assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings");
assert!(
matches!(back.source, MaskSource::Radial { .. }),
"and its kind: {:?}",
back.source.kind()
);
}
#[test]
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
// Replaced, not appended, so a RAW and the JPEG beside it share one
// sidecar — they are the same photograph (FR-CAT-11).
assert_eq!(
local_sidecar_path(Path::new("/photos/a.CR2")),
PathBuf::from("/photos/a.drsc")
);
}
#[test]
fn an_edit_survives_a_save_and_a_load() {
use dr_pipeline::ops::exposure;
let dir = tempdir("round-trip");
let image = dir.join("a.CR2");
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();
sidecar
.default_version()
.expect("a version")
.apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
}
#[test]
fn an_image_with_no_sidecar_loads_as_nothing() {
let dir = tempdir("absent");
assert!(load_local(&dir.join("never-edited.CR2")).is_none());
}
#[test]
fn saving_twice_amends_one_version_rather_than_accumulating_them() {
// A random uuid per save would leave the file growing a version every
// time the user left the develop view, and `default_version` would
// start answering with whichever sorted first.
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();
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
}
#[test]
fn a_save_bumps_the_revision() {
// FR-NC-9 resolves conflicts by revision; a write that did not bump it
// would lose to a stale remote copy at the next sync.
let dir = tempdir("revision");
let image = dir.join("a.CR2");
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();
let second = load_local(&image)
.unwrap()
.default_version()
.unwrap()
.revision;
assert!(second > first, "{second} did not follow {first}");
}
#[test]
fn a_save_does_not_destroy_a_rating_it_never_read() {
// The read-modify-write property. A cull is stored in the same version
// as the edit, and leaving the develop view must not wipe it.
let dir = tempdir("keeps-rating");
let image = dir.join("a.CR2");
let mut sidecar = Sidecar::new();
sidecar.put(dr_pipeline::sidecar::Version {
uuid: "local".to_string(),
name: "Default".to_string(),
is_default: true,
rating: 4,
flag: 1,
..Default::default()
});
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let back = load_local(&image).unwrap();
let v = back.default_version().unwrap();
assert_eq!(v.rating, 4, "the cull was destroyed by an edit");
assert_eq!(v.flag, 1);
}
#[test]
fn a_save_refuses_to_overwrite_an_unreadable_sidecar() {
// The file may hold an edit written by a newer build. Losing it
// because this one could not parse it is the worst available failure
// for an authoritative store.
let dir = tempdir("refuse");
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_eq!(
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
"drsc 99\n",
"the file was modified despite the refusal"
);
}
#[test]
fn saving_leaves_no_temporary_file_behind() {
let dir = tempdir("no-temp");
save_local(
&dir.join("a.CR2"),
&Preset::capture(&edited()),
Scope::Everything,
None,
)
.unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "left {leftovers:?} behind");
}
// --- the clipboard ------------------------------------------------------
#[test]
fn a_fresh_clipboard_is_not_armed() {
assert!(!Clipboard::default().is_armed());
}
#[test]
fn the_scope_follows_the_setting_and_defaults_to_sparing_the_crop() {
let mut settings = dr_types::Settings::default();
assert_eq!(
scope_for(&settings),
Scope::Adjustments,
"a fresh install must not carry crops between photographs"
);
settings.develop.copy_includes_framing = true;
assert_eq!(scope_for(&settings), Scope::Everything);
}
#[test]
fn the_description_counts_operations_and_not_parameters() {
use dr_pipeline::ops::{exposure, white_balance};
let clipboard = Clipboard::default();
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 20.0);
g.set_param(white_balance::ID, white_balance::TINT, 5.0);
clipboard.put(Preset::capture(&g));
// Three parameters, two operations.
assert_eq!(clipboard.describe(Scope::Adjustments), "2 adjustments");
}
#[test]
fn a_neutral_copy_is_armed_and_says_so() {
// Copying an unedited frame and pasting it clears the target, which is
// a real action — so the button must be live rather than looking like
// nothing was copied.
let clipboard = Clipboard::default();
clipboard.put(Preset::capture(&EditGraph::default_chain()));
assert!(clipboard.is_armed());
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
}
}