Five looks for "more colourful than the default": Vivid, Vivid strong, Vivid landscape, Vivid warm and Vivid portrait. They lean on vibrance, which lifts muted colours most and holds skin back, and use saturation sparingly on top; landscape and portrait work the colour mixer's bands so foliage and sky get richer while skin does not. A sixth, Stronger camera look, pushes the camera profile's look table to 175 %, about the step from Adobe Standard to Adobe Vivid, and only moves vibrance where a photograph has no profile. All change only what they name, so they keep a corrected exposure or white balance, and the shipped-preset tests bound every key and value.
2132 lines
80 KiB
Rust
2132 lines
80 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, ScopeKind, Steps, Transfer};
|
|
|
|
/// 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 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 {
|
|
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::Compose => "Compose",
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// 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(mut s) => {
|
|
// TRACES: FR-NC-8 | FR-NC-9
|
|
// A local sidecar is only local to *this* app. The folder it sits
|
|
// in may well be synced by something else — a Nextcloud desktop
|
|
// client, a git-annex, a memory card carried between machines — so
|
|
// it can hold the same two-default split a library sidecar does,
|
|
// and opening the photograph must show both halves.
|
|
s.fuse_default_versions(None);
|
|
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>,
|
|
snapshots: Option<(&[dr_pipeline::Version], &[String])>,
|
|
) -> 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()
|
|
)
|
|
})?,
|
|
};
|
|
|
|
// TRACES: FR-NC-8 | FR-NC-9
|
|
// Fold any split down before choosing which version to amend, so a save
|
|
// resolves the two rather than writing into one of them and leaving the
|
|
// other to be picked next time.
|
|
sidecar.fuse_default_versions(None);
|
|
|
|
// 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);
|
|
// TRACES: FR-DEV-5
|
|
if let Some((kept, removed)) = snapshots {
|
|
sidecar.replace_snapshots(&uuid, kept.to_vec(), removed);
|
|
}
|
|
|
|
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>,
|
|
) {
|
|
// All 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, snapshots, removed)) = session.borrow().as_ref().map(|s| {
|
|
(
|
|
s.copy_settings(),
|
|
// TRACES: FR-DEV-3
|
|
// Not `masks().clone()`: this is the stack *with the model's
|
|
// coverage folded in*, so a subject or category layer survives
|
|
// being closed and reopened. Without it the sidecar stored the
|
|
// layer's identity and nothing else, and the next session — or a
|
|
// batch export, which never runs a model — rendered the
|
|
// photograph with the local adjustment silently missing.
|
|
s.masks_for_storage(),
|
|
// 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()
|
|
}),
|
|
}),
|
|
// TRACES: FR-DEV-5
|
|
// The snapshots as they stand and the ids deleted this sitting,
|
|
// so the file gains what was taken and loses what was removed —
|
|
// and keeps what another device added meanwhile.
|
|
s.snapshots().to_vec(),
|
|
s.removed_snapshots().to_vec(),
|
|
)
|
|
}) 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),
|
|
Some((&snapshots, &removed)),
|
|
) {
|
|
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),
|
|
snapshots: Some((snapshots, removed)),
|
|
},
|
|
};
|
|
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.global::<Steps>().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);
|
|
// TRACES: FR-DEV-5
|
|
// And the snapshots taken of it, on this device or another.
|
|
s.set_snapshots(
|
|
sidecar
|
|
.snapshots_of(&version.uuid)
|
|
.into_iter()
|
|
.cloned()
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
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());
|
|
let transfer = window.global::<Transfer>();
|
|
transfer.set_armed(clipboard.is_armed());
|
|
transfer.set_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.
|
|
transfer.set_framing_withheld(
|
|
clipboard.holds_framing() && !scope.has(dr_pipeline::Attribute::Compose),
|
|
);
|
|
}
|
|
|
|
/// 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.global::<Transfer>().on_copy(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.global::<Transfer>().on_paste(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>,
|
|
/// The category folders the photographer has opened, by key (see
|
|
/// [`PresetTree`]). Every folder starts closed, so a list of seventy
|
|
/// presets arrives as six lines; for the session, not saved, as the
|
|
/// collections tree remembers what it had open.
|
|
open: RefCell<std::collections::HashSet<String>>,
|
|
}
|
|
|
|
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> {
|
|
// TRACES: FR-DEV-6
|
|
// Nothing is seeded any more: the shipped presets are read from the
|
|
// binary every time (`dr_pipeline::bundled`). What a first run of an
|
|
// older build copied into this file is forgotten here where it is
|
|
// still exactly as copied — otherwise all six would list as changed
|
|
// and stay frozen at their old values. In memory only; the next save
|
|
// writes the file without them, and until then nothing is lost,
|
|
// because each one is identical to the preset now shipped in its
|
|
// place.
|
|
let mut library = store.load();
|
|
let forgotten = dr_pipeline::bundled::forget_unchanged_copies(&mut library);
|
|
if forgotten > 0 {
|
|
log::info!("{forgotten} seeded preset copies are now shipped presets");
|
|
}
|
|
Rc::new(Self {
|
|
store,
|
|
library: RefCell::new(library),
|
|
open: RefCell::default(),
|
|
})
|
|
}
|
|
|
|
/// Read presets from a file or a folder and store them.
|
|
///
|
|
/// Returns what to tell the user. Names already in the library are
|
|
/// replaced, the same rule a save follows — importing the same folder
|
|
/// twice leaves one copy of each rather than `Warm Portrait 2`.
|
|
fn import(&self, path: &Path) -> String {
|
|
let report = dr_preset_xmp::read_path(path);
|
|
if report.presets.is_empty() {
|
|
return if report.failed > 0 {
|
|
format!("Found {} file(s), but none could be read.", report.failed)
|
|
} else {
|
|
"No .xmp presets there.".to_string()
|
|
};
|
|
}
|
|
|
|
let added = report.presets.len();
|
|
{
|
|
let mut library = self.library.borrow_mut();
|
|
for (name, preset) in report.presets {
|
|
if let Err(e) = library.insert(&name, preset) {
|
|
log::warn!("imported preset {name:?} is unusable ({e:?}); skipping");
|
|
}
|
|
}
|
|
}
|
|
if let Err(e) = self.persist(|_| {}) {
|
|
return e.message();
|
|
}
|
|
|
|
let mut message = format!(
|
|
"Imported {added} preset{}.",
|
|
if added == 1 { "" } else { "s" }
|
|
);
|
|
if report.failed > 0 {
|
|
message.push_str(&format!(" {} could not be read.", report.failed));
|
|
}
|
|
// Said once, however many files carried it: after forty presets the
|
|
// useful sentence is that the white balance did not come across.
|
|
if !report.unsupported.is_empty() {
|
|
message.push_str(" White balance and tone curves do not carry across.");
|
|
}
|
|
message
|
|
}
|
|
|
|
/// The photographer's own stored names, in the order they are written.
|
|
#[cfg(test)]
|
|
pub fn names(&self) -> Vec<String> {
|
|
self.library.borrow().names().map(String::from).collect()
|
|
}
|
|
|
|
/// The preset `name` applies: the photographer's if they have one under
|
|
/// that name, the shipped one otherwise.
|
|
pub fn get(&self, name: &str) -> Option<Preset> {
|
|
dr_pipeline::bundled::lookup(&self.library.borrow(), name)
|
|
}
|
|
|
|
/// Every row the menu and the sheet list: the category folders, and
|
|
/// the presets inside the ones that are open.
|
|
///
|
|
/// The photographer's own section is left out while it is empty: a
|
|
/// folder over nothing reads as a list that failed to load.
|
|
pub fn rows(&self) -> Vec<crate::PresetRow> {
|
|
let open = self.open.borrow();
|
|
self.tree().flatten(&|key| open.contains(key))
|
|
}
|
|
|
|
/// Every row with every folder open — what the tests read.
|
|
#[cfg(test)]
|
|
pub fn all_rows(&self) -> Vec<crate::PresetRow> {
|
|
self.tree().flatten(&|_| true)
|
|
}
|
|
|
|
/// Open a closed folder, or close an open one.
|
|
pub fn toggle(&self, key: &str) {
|
|
let mut open = self.open.borrow_mut();
|
|
if !open.remove(key) {
|
|
open.insert(key.to_string());
|
|
}
|
|
}
|
|
|
|
/// Open every folder `name` is filed in, so a preset just saved or
|
|
/// renamed is on screen rather than inside a closed folder.
|
|
fn reveal(&self, name: &str) {
|
|
let name = name.trim();
|
|
// A changed copy lists in its shipped section, which the name does
|
|
// not say; your own are filed by the name's own categories.
|
|
let keys = match dr_pipeline::bundled::sections()
|
|
.into_iter()
|
|
.find(|section| section.presets.contains(name))
|
|
{
|
|
Some(section) => PresetTree::keys(SHIPPED, section.title),
|
|
None => {
|
|
let mut keys = vec![YOURS.to_string()];
|
|
if let Some((folders, _)) = name.rsplit_once('/') {
|
|
keys.extend(PresetTree::keys(YOURS, folders));
|
|
}
|
|
keys
|
|
}
|
|
};
|
|
self.open.borrow_mut().extend(keys);
|
|
}
|
|
|
|
/// The listing, filed into folders.
|
|
fn tree(&self) -> PresetTree {
|
|
use dr_pipeline::bundled::Origin;
|
|
let mut tree = PresetTree::default();
|
|
for section in dr_pipeline::bundled::listing(&self.library.borrow()) {
|
|
for row in section.rows {
|
|
let origin = match row.origin {
|
|
Origin::Yours => crate::PresetOrigin::Yours,
|
|
Origin::Shipped => crate::PresetOrigin::Shipped,
|
|
Origin::Changed => crate::PresetOrigin::Changed,
|
|
};
|
|
match section.id {
|
|
// Your own are filed by their names' own categories,
|
|
// under one folder of their own.
|
|
None => tree.file(YOURS, section.title, &row.name, origin),
|
|
// A shipped preset's name is a name, not a path: the
|
|
// section title is its category.
|
|
Some(_) => {
|
|
let (root, label) = (SHIPPED, section.title);
|
|
tree.file_in(root, label, &row.name, &row.name, origin)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tree
|
|
}
|
|
|
|
/// Whether a preset is stored under `name`.
|
|
#[cfg(test)]
|
|
pub fn contains(&self, name: &str) -> bool {
|
|
self.library.borrow().contains(name)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// The rollback is the caller's because only the caller knows what the
|
|
/// previous state was. An import passes an empty one deliberately: it may
|
|
/// have replaced any number of names, restoring them all would mean
|
|
/// snapshotting the whole library, and the honest failure there is to say
|
|
/// the write failed and leave what is on screen matching what is in
|
|
/// memory until the next successful save.
|
|
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.
|
|
#[derive(Debug)]
|
|
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}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read Lightroom presets from a file or a folder and say what came of it.
|
|
///
|
|
/// Synchronous, deliberately. A preset folder is a few hundred small text
|
|
/// files and the read is milliseconds; a worker and a progress bar would be
|
|
/// machinery around a wait nobody sees.
|
|
fn import_presets(w: &AppWindow, named: &Rc<NamedPresets>, path: &Path) {
|
|
if path.as_os_str().is_empty() {
|
|
return;
|
|
}
|
|
let report = named.import(path);
|
|
w.set_preset_import_report(report.into());
|
|
render_named(w, named);
|
|
}
|
|
|
|
/// The key of the photographer's own folder, and the prefix of every
|
|
/// folder inside it. Keys are never shown; they only have to stay the same
|
|
/// while the list is rebuilt around them.
|
|
const YOURS: &str = "yours";
|
|
/// The prefix of every shipped folder's key.
|
|
const SHIPPED: &str = "shipped";
|
|
|
|
/// TRACES: FR-DEV-6
|
|
/// The presets filed into folders, before they are flattened into rows.
|
|
///
|
|
/// # Categories are paths
|
|
///
|
|
/// A preset called `Portraits/Warm skin` is `Warm skin`, filed in
|
|
/// `Portraits`. The category is spelled in the name rather than stored
|
|
/// beside it, which costs nothing in the file format: an older build reads
|
|
/// the same file and lists the whole path as the name. Renaming is how a
|
|
/// preset moves, and a Lightroom import names what it reads after the
|
|
/// folders it was in (`dr_preset_xmp::read_path`). Shipped sections use the
|
|
/// same spelling for their titles, so `Film/Colour` is a folder in `Film`.
|
|
///
|
|
/// # Flattened in Rust, as the collections tree is
|
|
///
|
|
/// Slint cannot nest a repeater inside a `Flickable` with no layout to size
|
|
/// it, so the rows arrive flat, each carrying its depth, and the list draws
|
|
/// indentation from that integer.
|
|
#[derive(Default)]
|
|
struct PresetTree {
|
|
/// Top-level folders, in the order they were first filed into.
|
|
folders: Vec<Folder>,
|
|
}
|
|
|
|
struct Folder {
|
|
key: String,
|
|
label: String,
|
|
folders: Vec<Folder>,
|
|
presets: Vec<(String, String, crate::PresetOrigin)>,
|
|
}
|
|
|
|
impl Folder {
|
|
fn new(key: String, label: &str) -> Self {
|
|
Self {
|
|
key,
|
|
label: label.to_string(),
|
|
folders: Vec::new(),
|
|
presets: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The folder labelled `label` directly inside this one, made if absent.
|
|
fn child(&mut self, label: &str) -> &mut Folder {
|
|
let at = match self.folders.iter().position(|f| f.label == label) {
|
|
Some(at) => at,
|
|
None => {
|
|
let key = format!("{}/{label}", self.key);
|
|
self.folders.push(Folder::new(key, label));
|
|
self.folders.len() - 1
|
|
}
|
|
};
|
|
&mut self.folders[at]
|
|
}
|
|
|
|
/// How many presets this folder holds, however deep.
|
|
fn count(&self) -> usize {
|
|
self.presets.len() + self.folders.iter().map(Folder::count).sum::<usize>()
|
|
}
|
|
}
|
|
|
|
impl PresetTree {
|
|
/// The keys of `path`'s folders under `root`, outermost first — what
|
|
/// has to be open for something filed at `path` to be on screen.
|
|
fn keys(root: &str, path: &str) -> Vec<String> {
|
|
let mut key = root.to_string();
|
|
let mut keys = Vec::new();
|
|
for part in path.split('/').filter(|p| !p.is_empty()) {
|
|
key = format!("{key}/{part}");
|
|
keys.push(key.clone());
|
|
}
|
|
keys
|
|
}
|
|
|
|
/// The top-level folder `label`, under the key prefix `root`.
|
|
fn top(&mut self, key: String, label: &str) -> &mut Folder {
|
|
let at = match self.folders.iter().position(|f| f.key == key) {
|
|
Some(at) => at,
|
|
None => {
|
|
self.folders.push(Folder::new(key, label));
|
|
self.folders.len() - 1
|
|
}
|
|
};
|
|
&mut self.folders[at]
|
|
}
|
|
|
|
/// File `name` into `root`'s folder called `title`, its own categories
|
|
/// taken from the name.
|
|
fn file(&mut self, root: &str, title: &str, name: &str, origin: crate::PresetOrigin) {
|
|
let mut folder = self.top(root.to_string(), title);
|
|
let mut parts: Vec<&str> = name.split('/').map(str::trim).collect();
|
|
// A name that is all separators still has to be applicable: its
|
|
// leaf is the whole name.
|
|
let leaf = parts.pop().filter(|l| !l.is_empty()).unwrap_or(name);
|
|
for part in parts.into_iter().filter(|p| !p.is_empty()) {
|
|
folder = folder.child(part);
|
|
}
|
|
folder
|
|
.presets
|
|
.push((leaf.to_string(), name.to_string(), origin));
|
|
}
|
|
|
|
/// File `name`, shown as `label`, at the category path `path`.
|
|
fn file_in(
|
|
&mut self,
|
|
root: &str,
|
|
path: &str,
|
|
label: &str,
|
|
name: &str,
|
|
origin: crate::PresetOrigin,
|
|
) {
|
|
let mut parts = path.split('/').filter(|p| !p.is_empty());
|
|
let Some(first) = parts.next() else { return };
|
|
let mut folder = self.top(format!("{root}/{first}"), first);
|
|
for part in parts {
|
|
folder = folder.child(part);
|
|
}
|
|
folder
|
|
.presets
|
|
.push((label.to_string(), name.to_string(), origin));
|
|
}
|
|
|
|
/// The rows, with the contents of every folder `open` says is open.
|
|
///
|
|
/// Folders before presets at each level, each in the order filed —
|
|
/// which for your own is by name, as the library stores them.
|
|
fn flatten(&self, open: &dyn Fn(&str) -> bool) -> Vec<crate::PresetRow> {
|
|
fn visit(
|
|
folder: &Folder,
|
|
depth: i32,
|
|
open: &dyn Fn(&str) -> bool,
|
|
rows: &mut Vec<crate::PresetRow>,
|
|
) {
|
|
let expanded = open(&folder.key);
|
|
rows.push(crate::PresetRow {
|
|
folder: true,
|
|
key: folder.key.clone().into(),
|
|
label: folder.label.clone().into(),
|
|
depth,
|
|
expanded,
|
|
count: folder.count() as i32,
|
|
name: Default::default(),
|
|
origin: crate::PresetOrigin::Shipped,
|
|
});
|
|
if !expanded {
|
|
return;
|
|
}
|
|
for child in &folder.folders {
|
|
visit(child, depth + 1, open, rows);
|
|
}
|
|
for (label, name, origin) in &folder.presets {
|
|
rows.push(crate::PresetRow {
|
|
folder: false,
|
|
key: Default::default(),
|
|
label: label.clone().into(),
|
|
depth: depth + 1,
|
|
expanded: false,
|
|
count: 0,
|
|
name: name.clone().into(),
|
|
origin: *origin,
|
|
});
|
|
}
|
|
}
|
|
let mut rows = Vec::new();
|
|
for folder in self.folders.iter().filter(|f| f.count() > 0) {
|
|
visit(folder, 0, open, &mut rows);
|
|
}
|
|
rows
|
|
}
|
|
}
|
|
|
|
/// Push the listing onto the window.
|
|
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
|
|
window.set_preset_rows(slint::ModelRc::new(slint::VecModel::from(named.rows())));
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// --- open and close a category folder ---------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let named = named.clone();
|
|
window.on_toggle_preset_folder(move |key| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
named.toggle(&key);
|
|
render_named(&w, &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());
|
|
named.reveal(&name);
|
|
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());
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- opening the sheet over the open photograph -----------------------
|
|
//
|
|
// "Save or manage…" in the tool rail's presets menu, and everything
|
|
// opening the sheet from there means. It used to be four lines inside the panel's
|
|
// instantiation in `app.slint`, which is exactly the sort of behaviour a
|
|
// second drawing of that panel would have had to copy; `Transfer` is a
|
|
// global and a global cannot bind the window's own state, so it lands
|
|
// here instead — where the summary it also has to compute already lived.
|
|
//
|
|
// The grid opens the same sheet with its own count and no capture summary,
|
|
// still from `app.slint`: nothing there is worth saving, so `can-save` is
|
|
// false and the summary would describe an edit that is not in hand.
|
|
{
|
|
let weak = window.as_weak();
|
|
let session = session.clone();
|
|
window.global::<Transfer>().on_open_presets(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());
|
|
// Opened with no count, which is what tells the apply handler this
|
|
// means the open photograph rather than a selection.
|
|
w.set_preset_apply_count(0);
|
|
// `crate::Develop` spelled out: this module has a `Develop` of its
|
|
// own, and it is the bundle of session handles below, not the
|
|
// interface global.
|
|
w.set_preset_can_save(w.global::<crate::Develop>().get_enabled());
|
|
w.set_presets_open(true);
|
|
});
|
|
}
|
|
|
|
// --- 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);
|
|
});
|
|
}
|
|
|
|
// --- import from Lightroom --------------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let named = named.clone();
|
|
window.on_import_presets(move |path| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
import_presets(&w, &named, Path::new(path.trim()));
|
|
});
|
|
}
|
|
{
|
|
let weak = window.as_weak();
|
|
let named = named.clone();
|
|
window.on_browse_presets(move |file| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let (title, pick) = if file {
|
|
(
|
|
"Import a Lightroom preset",
|
|
crate::folder_dialog::Pick::File(&["xmp"]),
|
|
)
|
|
} else {
|
|
(
|
|
"Import a folder of Lightroom presets",
|
|
crate::folder_dialog::Pick::Folder,
|
|
)
|
|
};
|
|
let (weak, named) = (weak.clone(), named.clone());
|
|
crate::folder_dialog::ask(&w, title, pick, None, move |path| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
import_presets(&w, &named, &path);
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- 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();
|
|
// Renaming is how a preset changes folder, so the one it
|
|
// moved into is opened to show where it went.
|
|
named.reveal(&to);
|
|
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()),
|
|
None,
|
|
)
|
|
.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.base().source, MaskSource::Radial { .. }),
|
|
"and its kind: {:?}",
|
|
back.base().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,
|
|
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,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
save_local(
|
|
&image,
|
|
&Preset::capture(&edited()),
|
|
Scope::everything(),
|
|
None,
|
|
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,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let first = load_local(&image)
|
|
.unwrap()
|
|
.default_version()
|
|
.unwrap()
|
|
.revision;
|
|
save_local(
|
|
&image,
|
|
&Preset::capture(&edited()),
|
|
Scope::everything(),
|
|
None,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// A library in a fresh directory of its own.
|
|
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
|
|
let dir = tempdir(name);
|
|
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
|
|
}
|
|
|
|
/// The presets directly inside the folder labelled `title`, as (name,
|
|
/// origin), with every folder open.
|
|
fn section(presets: &NamedPresets, title: &str) -> Vec<(String, crate::PresetOrigin)> {
|
|
let rows = presets.all_rows();
|
|
let Some(at) = rows.iter().position(|r| r.folder && r.label == title) else {
|
|
return Vec::new();
|
|
};
|
|
let depth = rows[at].depth;
|
|
rows[at + 1..]
|
|
.iter()
|
|
.take_while(|r| r.depth > depth)
|
|
.filter(|r| !r.folder && r.depth == depth + 1)
|
|
.map(|r| (r.name.to_string(), r.origin))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_first_run_lists_the_shipped_presets_and_writes_nothing() {
|
|
// A sheet that opens on "No presets yet" teaches the photographer
|
|
// that the feature is homework — and a copy written into their file
|
|
// would be frozen at this release's values.
|
|
let (presets, dir) = named("first-run");
|
|
assert!(presets.names().is_empty());
|
|
assert!(!section(&presets, "Essentials").is_empty());
|
|
assert!(presets.get("Punch").is_some());
|
|
assert!(!dir.join("presets.drpl").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_section_of_your_own_is_not_listed() {
|
|
let (presets, _dir) = named("no-yours");
|
|
assert!(presets.all_rows().iter().all(|r| r.label != "Yours"));
|
|
presets.insert("Mine", Preset::default()).unwrap();
|
|
assert_eq!(
|
|
section(&presets, "Yours"),
|
|
vec![("Mine".to_string(), crate::PresetOrigin::Yours)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_seeded_copies_of_an_older_build_become_the_shipped_presets() {
|
|
// A library as an older first run wrote it: the six, verbatim.
|
|
let dir = tempdir("seeded-copies");
|
|
let path = dir.join("presets.drpl");
|
|
let essentials = dr_pipeline::bundled::sections()
|
|
.into_iter()
|
|
.find(|s| s.id == "essentials")
|
|
.unwrap()
|
|
.presets;
|
|
let mut seeded = PresetLibrary::default();
|
|
for (name, preset) in essentials.iter() {
|
|
seeded
|
|
.insert(name, preset.clone().with_reach(dr_pipeline::Reach::Whole))
|
|
.unwrap();
|
|
}
|
|
PresetStore::open_at(path.clone()).save(&seeded).unwrap();
|
|
|
|
let presets = NamedPresets::open_at(path);
|
|
assert!(presets.names().is_empty(), "{:?}", presets.names());
|
|
assert!(section(&presets, "Essentials")
|
|
.iter()
|
|
.all(|(_, origin)| *origin == crate::PresetOrigin::Shipped));
|
|
}
|
|
|
|
#[test]
|
|
fn every_folder_starts_closed_and_opens_on_its_own() {
|
|
// Seventy shipped presets arrive as the handful of folders they are
|
|
// filed in, and opening one lists what is in it and nothing else.
|
|
let (presets, _dir) = named("closed");
|
|
let rows = presets.rows();
|
|
assert!(rows.iter().all(|r| r.folder && r.depth == 0), "{rows:?}");
|
|
let labels: Vec<_> = rows.iter().map(|r| r.label.to_string()).collect();
|
|
assert_eq!(labels, ["Essentials", "Skies", "Vivid", "Film"]);
|
|
|
|
presets.toggle("shipped/Film");
|
|
let film: Vec<_> = presets
|
|
.rows()
|
|
.into_iter()
|
|
.filter(|r| r.depth == 1)
|
|
.map(|r| r.label.to_string())
|
|
.collect();
|
|
assert_eq!(film, ["Colour", "Cinema", "Black and white"]);
|
|
|
|
presets.toggle("shipped/Film");
|
|
assert_eq!(presets.rows().len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn a_slash_in_your_name_files_it_in_a_folder_of_that_name() {
|
|
let (presets, _dir) = named("categories");
|
|
presets
|
|
.insert("Portraits/Warm skin", Preset::default())
|
|
.unwrap();
|
|
presets.insert("Loose", Preset::default()).unwrap();
|
|
|
|
// The category's own presets, shown by their last part and applied
|
|
// by the whole name.
|
|
assert_eq!(
|
|
section(&presets, "Portraits"),
|
|
vec![(
|
|
"Portraits/Warm skin".to_string(),
|
|
crate::PresetOrigin::Yours
|
|
)]
|
|
);
|
|
let rows = presets.all_rows();
|
|
let yours = rows.iter().find(|r| r.label == "Yours").unwrap();
|
|
assert_eq!(yours.count, 2);
|
|
let warm = rows
|
|
.iter()
|
|
.find(|r| r.name == "Portraits/Warm skin")
|
|
.unwrap();
|
|
assert_eq!((warm.label.as_str(), warm.depth), ("Warm skin", 2));
|
|
}
|
|
|
|
#[test]
|
|
fn saving_into_a_folder_opens_the_way_to_it() {
|
|
let (presets, _dir) = named("reveal");
|
|
presets
|
|
.insert("Travel/Night/Neon", Preset::default())
|
|
.unwrap();
|
|
presets.reveal("Travel/Night/Neon");
|
|
assert!(presets.rows().iter().any(|r| r.name == "Travel/Night/Neon"));
|
|
}
|
|
|
|
#[test]
|
|
fn saving_over_a_shipped_name_changes_it_and_deleting_reverts() {
|
|
let (presets, _dir) = named("override");
|
|
let shipped = presets.get("Punch").unwrap();
|
|
|
|
presets.insert("Punch", Preset::capture(&edited())).unwrap();
|
|
assert_eq!(presets.get("Punch"), Some(Preset::capture(&edited())));
|
|
assert!(section(&presets, "Essentials")
|
|
.contains(&("Punch".to_string(), crate::PresetOrigin::Changed)));
|
|
|
|
presets.library.borrow_mut().remove("Punch");
|
|
assert_eq!(presets.get("Punch"), Some(shipped));
|
|
}
|
|
|
|
/// TRACES: FR-DEV-6 | FR-DEV-3f
|
|
/// Every stock a shipped preset names is one this build can bake, on the
|
|
/// paper the preset says.
|
|
///
|
|
/// Here rather than beside the presets: `dr-pipeline` does not link the
|
|
/// profile database, and a misspelt stock there would ship a preset that
|
|
/// logs a warning and develops without a film. The paper is checked too,
|
|
/// because the session prints on a stock's *own* paper and reads only
|
|
/// whether one was asked for — a preset naming another would say one
|
|
/// thing and render another.
|
|
#[test]
|
|
fn every_shipped_film_is_a_stock_this_build_can_bake() {
|
|
for section in dr_pipeline::bundled::sections() {
|
|
for (name, preset) in section.presets.iter() {
|
|
let Some(film) = preset.film() else { continue };
|
|
let profile = dr_film::find(&film.stock)
|
|
.unwrap_or_else(|| panic!("{name}: no stock {:?}", film.stock));
|
|
if let Some(print) = &film.print {
|
|
let paper = dr_film::default_print(profile)
|
|
.unwrap_or_else(|| panic!("{name}: {} has no paper", film.stock));
|
|
assert_eq!(&paper.stock, print, "{name}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn importing_a_folder_stores_what_it_finds() {
|
|
let (presets, dir) = named("import-folder");
|
|
let from = dir.join("from-lightroom");
|
|
std::fs::create_dir_all(&from).unwrap();
|
|
std::fs::write(
|
|
from.join("warm.xmp"),
|
|
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
|
|
<crs:Exposure2012>+0.5</crs:Exposure2012>
|
|
</rdf:Description>
|
|
</rdf:RDF>
|
|
</x:xmpmeta>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let report = presets.import(&from);
|
|
assert!(report.starts_with("Imported 1 preset."), "{report}");
|
|
assert_eq!(presets.names(), vec!["warm".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn importing_the_same_folder_twice_leaves_one_copy() {
|
|
let (presets, dir) = named("import-twice");
|
|
let from = dir.join("from-lightroom");
|
|
std::fs::create_dir_all(&from).unwrap();
|
|
std::fs::write(
|
|
from.join("warm.xmp"),
|
|
r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="+0.5"/>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
presets.import(&from);
|
|
presets.import(&from);
|
|
assert_eq!(presets.names(), vec!["warm".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn importing_from_nowhere_says_so_rather_than_claiming_success() {
|
|
let (presets, _dir) = named("import-nothing");
|
|
let report = presets.import(Path::new("/definitely/not/here"));
|
|
assert!(report.contains("No .xmp presets"), "{report}");
|
|
}
|
|
|
|
#[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()))
|
|
.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())
|
|
.expect_no_film();
|
|
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());
|
|
// What must not survive is the preset whose write failed.
|
|
assert!(
|
|
!presets.contains("Warm"),
|
|
"the failed save left a preset behind: {:?}",
|
|
presets.names()
|
|
);
|
|
}
|
|
|
|
#[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();
|
|
|
|
// Root is not refused by a mode, and CI's desktop job runs as root
|
|
// inside its container: there the directory is as writable as it ever
|
|
// was and the "failed" write succeeds. Probed rather than assumed from
|
|
// the uid, because what the test needs is the refusal itself, and a
|
|
// filesystem mounted without permission checks would pass the uid
|
|
// test and fail this one all the same.
|
|
let probe = dir.join("probe");
|
|
let enforced = std::fs::write(&probe, b"").is_err();
|
|
if !enforced {
|
|
let _ = std::fs::remove_file(&probe);
|
|
perms.set_mode(0o700);
|
|
std::fs::set_permissions(&dir, perms).unwrap();
|
|
eprintln!("directory modes are not enforced here (root?); skipping");
|
|
return;
|
|
}
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5
|
|
/// A snapshot goes into the file beside the edit and comes back out as a
|
|
/// snapshot of it; deleting one takes it out of the file; and neither
|
|
/// touches the edit the photograph opens at.
|
|
#[test]
|
|
fn snapshots_survive_the_local_sidecar_and_leave_it_when_deleted() {
|
|
let dir = tempdir("snapshots");
|
|
let image = dir.join("IMG_0001.CR3");
|
|
std::fs::write(&image, b"").unwrap();
|
|
|
|
let graph = edited();
|
|
let mut liked = EditGraph::default_chain();
|
|
liked.set_param(
|
|
dr_pipeline::ops::exposure::ID,
|
|
dr_pipeline::ops::exposure::EXPOSURE,
|
|
1.5,
|
|
);
|
|
let snapshot = dr_pipeline::Version::from_graph("snap-1", "Brighter", &liked);
|
|
|
|
save_local(
|
|
&image,
|
|
&Preset::capture(&graph),
|
|
Scope::everything(),
|
|
None,
|
|
Some((&[snapshot], &[])),
|
|
)
|
|
.unwrap();
|
|
|
|
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
|
|
let sidecar = Sidecar::parse(&text).unwrap();
|
|
let edit = sidecar.default_version().expect("the edit");
|
|
assert!(!edit.is_snapshot(), "the photograph opens at its edit");
|
|
let back = sidecar.snapshots_of(&edit.uuid);
|
|
assert_eq!(back.len(), 1);
|
|
assert_eq!(back[0].name, "Brighter");
|
|
assert_eq!(
|
|
back[0]
|
|
.params
|
|
.get(&("exposure".to_string(), "exposure".to_string())),
|
|
Some(&1.5)
|
|
);
|
|
|
|
save_local(
|
|
&image,
|
|
&Preset::capture(&graph),
|
|
Scope::everything(),
|
|
None,
|
|
Some((&[], &["snap-1".to_string()])),
|
|
)
|
|
.unwrap();
|
|
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
|
|
let sidecar = Sidecar::parse(&text).unwrap();
|
|
let edit = sidecar.default_version().expect("the edit is still there");
|
|
assert!(sidecar.snapshots_of(&edit.uuid).is_empty(), "{text}");
|
|
}
|
|
}
|