Files
DarkRoom/ui/dr-ui/src/presets.rs
T
dtourolleandClaude Opus 5 5a8327824f Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.

The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.

Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.

Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.

Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.

The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:07:14 +02:00

1223 lines
45 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::{NameError, Preset, PresetLibrary, Scope, Sidecar};
use slint::ComponentHandle;
use crate::develop::DevelopSession;
use crate::preset_store::PresetStore;
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();
};
describe(&preset, scope)
}
}
/// A short description of a preset's contents, for a label.
///
/// Free rather than a method on [`Clipboard`] because the named-preset sheet
/// describes what *saving* would capture, and a second phrasing of the same
/// count is a second thing to keep in step — the sheet saying "3 settings"
/// beside a panel saying "3 adjustments" would read as two different numbers.
pub fn describe(preset: &Preset, scope: Scope) -> String {
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,
);
});
}
}
// ---------------------------------------------------------------------------
// Named presets (FR-DEV-6)
// ---------------------------------------------------------------------------
/// TRACES: FR-DEV-6
/// The saved preset library, and the file it lives in.
///
/// # Why the library is held in memory as well as on disk
///
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
/// copy is what the sheet is drawn from and what the next edit is applied to.
/// Reading the file back after each change would be the same bytes and one
/// more chance for a failed read to empty a list the user is looking at.
///
/// # A failed save is reported, not swallowed
///
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
/// directory that is not writable. Losing a preset the user just named, with
/// the sheet cheerfully listing it, would be discovered at the worst possible
/// moment, so the write's result reaches the window.
pub struct NamedPresets {
store: PresetStore,
library: RefCell<PresetLibrary>,
}
impl NamedPresets {
/// Load the library from its usual place.
pub fn open() -> Rc<Self> {
Self::at(PresetStore::open())
}
/// Load from an explicit store — for tests, and for a non-default location.
#[cfg(test)]
pub fn open_at(path: PathBuf) -> Rc<Self> {
Self::at(PresetStore::open_at(path))
}
fn at(store: PresetStore) -> Rc<Self> {
let library = RefCell::new(store.load());
Rc::new(Self { store, library })
}
/// The stored names, in the order they are written.
pub fn names(&self) -> Vec<String> {
self.library.borrow().names().map(String::from).collect()
}
/// The preset stored under `name`.
pub fn get(&self, name: &str) -> Option<Preset> {
self.library.borrow().get(name).cloned()
}
/// Store `preset` under `name` and persist.
///
/// The in-memory library is updated first and rolled back if the write
/// fails, so what the sheet lists is always what is on disk. The
/// alternative — writing first — would mean holding a preset the file does
/// not have on every failure path.
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
let previous = {
let mut library = self.library.borrow_mut();
let existing = library.get(name.trim()).cloned();
library.insert(name, preset).map_err(SaveError::Name)?;
existing
};
self.persist(|library| match previous {
Some(p) => {
let _ = library.insert(name, p);
}
None => {
library.remove(name.trim());
}
})
}
/// Save the library, undoing the in-memory change if the write fails.
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
let result = self.store.save(&self.library.borrow());
match result {
Ok(()) => Ok(()),
Err(e) => {
rollback(&mut self.library.borrow_mut());
// Named, the way the settings page names its file: "could not
// save" without saying where leaves the user nothing to check
// and nothing to fix.
Err(SaveError::Write(format!(
"{}: {e}",
self.store.path().display()
)))
}
}
}
}
/// Why a preset could not be saved.
enum SaveError {
/// The name itself was refused.
Name(NameError),
/// The library could not be written.
Write(String),
}
impl SaveError {
/// What to put in front of the user.
///
/// The prose lives here rather than in `dr-pipeline`, which depends on
/// nothing and has no business holding user-facing strings.
fn message(&self) -> String {
match self {
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
Self::Name(NameError::Unrepresentable) => {
"A preset name cannot contain brackets or line breaks.".to_string()
}
Self::Write(e) => format!("Could not save presets: {e}"),
}
}
}
/// Push the stored names onto the window.
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
}
/// Wire saving, applying, renaming and deleting named presets.
///
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
/// same reason: applying a preset to the open image changes the graph, so it
/// has to rebuild the panel, redraw the canvas and know where to save.
pub fn wire_named(
window: &AppWindow,
named: Rc<NamedPresets>,
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;
render_named(window, &named);
// --- save the open edit under a name ---------------------------------
{
let weak = window.as_weak();
let named = named.clone();
let session = session.clone();
window.on_save_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
w.set_preset_name_error("Open a photograph first.".into());
return;
};
// Captured at full scope, exactly as a copy is: the scope is a
// decision about applying, and a preset that had already discarded
// the crop could never grow it back (see `Preset::capture`).
match named.insert(&name, preset) {
Ok(()) => {
w.set_preset_name_error(Default::default());
render_named(&w, &named);
}
Err(e) => w.set_preset_name_error(e.message().into()),
}
});
}
// A refusal the user has started correcting is stale, and a message that
// outlives its cause is one the user learns to ignore.
{
let weak = window.as_weak();
window.on_preset_name_edited(move |_| {
if let Some(w) = weak.upgrade() {
w.set_preset_name_error(Default::default());
}
});
}
// --- what saving would capture ----------------------------------------
{
let weak = window.as_weak();
let session = session.clone();
window.on_presets_opened(move || {
let Some(w) = weak.upgrade() else { return };
// At the scope a save would use, which is full: a preset keeps
// the framing it was captured with and drops it at apply time.
let summary = session
.borrow()
.as_ref()
.map(|s| describe(&s.copy_settings(), Scope::Everything))
.unwrap_or_default();
w.set_preset_capture_summary(summary.into());
});
}
// --- apply ------------------------------------------------------------
//
// One callback for both targets. Which one is meant is not a guess: the
// sheet was opened from a view that set `preset-apply-count`, and the
// label the user just read said "Applies to 12 selected photographs" or
// said nothing. Deciding here from the same number keeps the promise.
{
let weak = window.as_weak();
let named = named.clone();
let settings = settings.clone();
let library = library.clone();
let collections = collections.clone();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let open = open.clone();
window.on_apply_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let Some(preset) = named.get(&name) else {
// Another window may have deleted it since this list was drawn.
render_named(&w, &named);
return;
};
let scope = scope_for(&settings.snapshot());
if w.get_preset_apply_count() > 0 {
library_ui::paste_settings_to_selection(
&w,
&library,
&collections.selected(),
&preset,
scope,
);
} else {
{
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
s.apply_settings(&preset, scope);
}
// The same three steps a paste takes, for the same reasons:
// many controls moved without any of them being touched, and
// a deliberate discrete action is saved immediately.
crate::sync_rows(&w, &rows, &session);
redraw(&w);
save_open_edit(&w, &open.borrow(), &session, &library);
}
w.set_presets_open(false);
});
}
// --- rename -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_rename_preset(move |from, to| {
let Some(w) = weak.upgrade() else { return };
let renamed = {
let mut library = named.library.borrow_mut();
library.rename(&from, &to)
};
match renamed {
Ok(_) => {
// Nothing to roll back to on a failed write beyond the
// name it had, which is what this restores.
let from = from.to_string();
let to = to.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.rename(&to, &from);
}) {
w.set_preset_name_error(e.message().into());
}
}
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
}
render_named(&w, &named);
});
}
// --- delete -----------------------------------------------------------
{
let weak = window.as_weak();
let named = named.clone();
window.on_delete_preset(move |name| {
let Some(w) = weak.upgrade() else { return };
let removed = {
let mut library = named.library.borrow_mut();
let previous = library.get(&name).cloned();
library.remove(&name);
previous
};
if let Some(previous) = removed {
let name = name.to_string();
if let Err(e) = named.persist(move |library| {
let _ = library.insert(&name, previous);
}) {
w.set_preset_name_error(e.message().into());
}
}
render_named(&w, &named);
});
}
}
/// 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");
}
// -----------------------------------------------------------------------
// Named presets
// -----------------------------------------------------------------------
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
}
#[test]
fn a_saved_preset_is_on_disk_before_the_call_returns() {
// Not on the way out, and not on a timer: a preset the user named and
// then lost to a crash is the one failure this feature cannot have.
let (presets, dir) = named("saved-immediately");
presets
.insert("Warm", Preset::capture(&edited()))
.ok()
.expect("saved");
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
}
#[test]
fn a_saved_preset_carries_the_edit_it_captured() {
let (presets, _dir) = named("carries-the-edit");
presets.insert("Warm", Preset::capture(&edited())).ok();
let preset = presets.get("Warm").expect("stored");
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::Adjustments);
assert_eq!(
target.param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE
),
Some(1.5)
);
}
#[test]
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
let (presets, _dir) = named("refused-name");
assert!(presets.insert("", Preset::default()).is_err());
assert!(presets.insert("bracket]", Preset::default()).is_err());
assert!(presets.names().is_empty());
}
#[test]
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
// The rollback. A sheet listing a preset the file does not have is a
// loss the user discovers later, at the moment they reach for it.
let dir = tempdir("failed-write");
// A *file* where the store wants a directory, so `create_dir_all`
// fails and the save cannot succeed.
let blocked = dir.join("blocked");
std::fs::write(&blocked, b"not a directory").unwrap();
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
assert!(presets.insert("Warm", Preset::default()).is_err());
assert!(
presets.names().is_empty(),
"the failed save left a preset behind"
);
}
#[test]
fn a_failed_overwrite_puts_the_original_back() {
// The other half of the rollback, and the one that loses work if it is
// wrong: overwriting is destructive, so a failed overwrite has to
// restore what was there rather than leave the name holding the new
// value the file never received.
use std::os::unix::fs::PermissionsExt;
let dir = tempdir("failed-overwrite");
let path = dir.join("presets.drpl");
let presets = NamedPresets::open_at(path.clone());
presets.insert("Warm", Preset::capture(&edited())).ok();
let original = presets.get("Warm").expect("stored");
// The directory exists, so `create_dir_all` still succeeds and it is
// the write of the temporary file that fails — which is the path a
// full disk takes.
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&dir, perms.clone()).unwrap();
let failed = presets.insert("Warm", Preset::default()).is_err();
// Restore the permissions before asserting, so a failure here does not
// leave an undeletable directory behind for the next run.
perms.set_mode(0o700);
std::fs::set_permissions(&dir, perms).unwrap();
assert!(failed, "the write should have failed");
assert_eq!(
presets.get("Warm"),
Some(original),
"the failed overwrite kept the new value"
);
}
}