A thumbnail comes from the file's embedded preview, which is the camera's idea of the photograph and knows nothing about what has been done to it since. So a frame could be cropped, turned upright and pulled two stops back, and the grid would go on showing the original — making the library, where a photographer spends most of their time, the one view in which an edit is invisible. The render is the framed output, not the sensor: `output_size` is what a crop, a quarter turn, a flip and a straighten all act on, so a thumbnail taken from the raw frame would be the right pixels in the wrong shape and still the wrong way up. It is the same path an export takes, at a size the store wants rather than at full resolution, and always sRGB — this is a JPEG in a shard that syncs between devices and is drawn as a cell, not a file anyone is finishing. Both size classes are replaced. The store keys on the class, so refreshing only the one the grid happens to be drawing leaves the other holding the unedited preview, and a zoom across the boundary would show the edit undoing itself. Each is rendered rather than downscaled from the larger, which would be a second and worse resampler than the GPU has already applied. It runs on the way out of develop, after the sidecar write is queued and never instead of it — the edit is what must not be lost, and a render that failed must not take the save down with it. Two cases are worth the work: an edit made in this sitting, which `can-undo` records even when it ends back at neutral, and an image opened with an edit already in its sidecar and left untouched, whose cached thumbnail has never shown that edit at all. A neutral image nobody touched fails both and costs nothing. Not covered: a batch paste onto a selection, which deliberately never opens a session — there is no rendered frame to take a thumbnail from, and downloading forty RAWs to make forty is exactly what that path exists to avoid.
704 lines
26 KiB
Rust
704 lines
26 KiB
Rust
//! TRACES: FR-DEV-6
|
|
//! Copying develop settings from one photograph to others.
|
|
//!
|
|
//! # The three pieces
|
|
//!
|
|
//! * A [`Clipboard`] — one [`Preset`] held for the life of the window. Not
|
|
//! the system clipboard: these are typed values addressed by `op.param`,
|
|
//! and putting them on a text clipboard would mean anything that happened
|
|
//! to be copied afterwards silently disarmed the paste.
|
|
//! * An [`OpenImage`] — where the develop view's edit is *stored*, which is
|
|
//! what a copy is taken from and what a paste has to be written back to.
|
|
//! * The batch, in [`paste_to_selection`], which never opens an image at all.
|
|
//!
|
|
//! # Why a paste can reach images that are not open
|
|
//!
|
|
//! Applying settings to forty frames by opening forty develop sessions would
|
|
//! mean forty downloads of a whole RAW file and forty demosaics, to change
|
|
//! some numbers. The edit is *data* — [`Preset::amend`] operates on the
|
|
//! parameter map a sidecar already stores — so the batch is a read-modify-write
|
|
//! per file and never touches a GPU. That is what makes it usable on a phone.
|
|
//!
|
|
//! The cost is that a batch paste is only visible once the target is opened
|
|
//! and its sidecar read, which is the load path this module also provides.
|
|
//!
|
|
//! # Two stores, because there are two ways an image is opened
|
|
//!
|
|
//! An image from the library grid lives on the server and its sidecar goes
|
|
//! beside it, through the same writer a rating uses. An image named on the
|
|
//! command line is a local file with no library behind it, and its sidecar is
|
|
//! written beside it on disk. Both are the same document in the same format —
|
|
//! only the transport differs, which is why [`Stored`] is an enum over where
|
|
//! rather than two notions of what.
|
|
|
|
use std::cell::RefCell;
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
|
|
use dr_pipeline::{Preset, Scope, Sidecar};
|
|
use slint::ComponentHandle;
|
|
|
|
use crate::develop::DevelopSession;
|
|
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
|
|
|
|
/// Where the develop view's current edit is stored.
|
|
///
|
|
/// `None` is not an error state: an image that failed to decode, or one opened
|
|
/// before a library was chosen, has nowhere to persist to and simply does not.
|
|
/// Copy still works from it — the graph is in memory either way — and it is
|
|
/// only the *saving* that has no destination.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub enum Stored {
|
|
#[default]
|
|
Nowhere,
|
|
/// A file on this device, named on the command line.
|
|
Local(PathBuf),
|
|
/// An image in the library. The uuid comes from the catalog so that this
|
|
/// device and every other name the same version (FR-NC-8).
|
|
Remote { path: String, version_uuid: String },
|
|
}
|
|
|
|
/// Which image the develop view is showing, and where its edit belongs.
|
|
pub type OpenImage = Rc<RefCell<Stored>>;
|
|
|
|
/// The settings clipboard.
|
|
///
|
|
/// Holds the preset at full scope; the [`Scope`] is applied at paste time from
|
|
/// the user's setting, so changing that setting after copying takes effect on
|
|
/// the next paste rather than requiring a fresh copy.
|
|
#[derive(Default)]
|
|
pub struct Clipboard {
|
|
preset: RefCell<Option<Preset>>,
|
|
}
|
|
|
|
impl Clipboard {
|
|
pub fn new() -> Rc<Self> {
|
|
Rc::new(Self::default())
|
|
}
|
|
|
|
/// Take a copy of a session's edit.
|
|
pub fn copy_from(&self, session: &DevelopSession) {
|
|
self.put(session.copy_settings());
|
|
}
|
|
|
|
/// Hold an already-captured preset.
|
|
pub fn put(&self, preset: Preset) {
|
|
*self.preset.borrow_mut() = Some(preset);
|
|
}
|
|
|
|
/// The held preset, if there is one.
|
|
pub fn peek(&self) -> Option<Preset> {
|
|
self.preset.borrow().clone()
|
|
}
|
|
|
|
/// Whether anything has been copied.
|
|
///
|
|
/// True even for a *neutral* copy. Copying an unedited photograph and
|
|
/// pasting it onto an edited one is a meaningful action — it clears the
|
|
/// target — so "has something been copied" is a different question from
|
|
/// "does the copy contain any values", and this is the first.
|
|
pub fn is_armed(&self) -> bool {
|
|
self.preset.borrow().is_some()
|
|
}
|
|
|
|
/// Whether the held preset carries framing that the current scope drops.
|
|
///
|
|
/// What the interface uses to explain a setting's effect on *this*
|
|
/// clipboard rather than in the abstract.
|
|
pub fn holds_framing(&self) -> bool {
|
|
self.preset
|
|
.borrow()
|
|
.as_ref()
|
|
.is_some_and(|p| p.touches_framing())
|
|
}
|
|
|
|
/// A short description of what would be pasted, for a button's label.
|
|
///
|
|
/// Counts operations rather than parameters: the colour mixer alone
|
|
/// declares thirty-six, and "36 settings" would say something about the
|
|
/// mixer's shape rather than about how much was copied.
|
|
pub fn describe(&self, scope: Scope) -> String {
|
|
let Some(preset) = self.preset.borrow().clone() else {
|
|
return String::new();
|
|
};
|
|
match preset.op_count(scope) {
|
|
0 => "Neutral".to_string(),
|
|
1 => "1 adjustment".to_string(),
|
|
n => format!("{n} adjustments"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The scope a paste should use, from the user's preference.
|
|
///
|
|
/// One function rather than the boolean read at each call site, so "the
|
|
/// setting is off by default and means framing stays behind" is stated once.
|
|
pub fn scope_for(settings: &dr_types::Settings) -> Scope {
|
|
if settings.develop.copy_includes_framing {
|
|
Scope::Everything
|
|
} else {
|
|
Scope::Adjustments
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local sidecars
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The sidecar path for a local image — the file's own path with the
|
|
/// extension replaced.
|
|
///
|
|
/// The same rule [`crate::library::sidecar_path`] applies to remote paths, so
|
|
/// a folder synced between the two is read identically from either side.
|
|
pub fn local_sidecar_path(image: &Path) -> PathBuf {
|
|
image.with_extension(dr_pipeline::sidecar::EXTENSION)
|
|
}
|
|
|
|
/// Read a local sidecar, if there is one.
|
|
///
|
|
/// Absence is the common case and is not an error. An *unreadable* file yields
|
|
/// `None` too, and [`save_local`] independently refuses to overwrite one — so
|
|
/// a sidecar this build cannot parse costs the edit being shown, never the
|
|
/// edit being kept.
|
|
pub fn load_local(image: &Path) -> Option<Sidecar> {
|
|
let path = local_sidecar_path(image);
|
|
let text = std::fs::read_to_string(&path).ok()?;
|
|
match Sidecar::parse(&text) {
|
|
Ok(s) => Some(s),
|
|
Err(e) => {
|
|
log::warn!("sidecar at {} is unreadable ({e})", path.display());
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read-modify-write a local sidecar.
|
|
///
|
|
/// Read first for the same reason the remote writer does: the file may already
|
|
/// hold a rating, or an operation this build does not know about, and writing
|
|
/// a fresh document containing only the current edit would delete both.
|
|
pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> 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);
|
|
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>,
|
|
) {
|
|
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) 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) {
|
|
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,
|
|
},
|
|
};
|
|
library_ui::start_sidecar_writes(window, library, vec![write]);
|
|
|
|
// TRACES: FR-CAT-9
|
|
// And bring the grid's cell up to date, so the photograph the user
|
|
// just finished is the photograph they see in the library.
|
|
//
|
|
// After the sidecar write is queued, never instead of it: the edit
|
|
// is the thing that must not be lost, and a render that failed
|
|
// must not take the save down with it.
|
|
refresh_thumbnail_for(window, path, session, library);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CAT-9
|
|
/// Re-render the grid's thumbnail from the edit that is being saved.
|
|
///
|
|
/// # When it is worth doing
|
|
///
|
|
/// Two cases, and the second is the one that is easy to miss.
|
|
///
|
|
/// An edit made in this sitting is the obvious one — `can-undo` is the app's
|
|
/// own record that something was changed, and it stays true for a change that
|
|
/// happened to end at neutral, which still needs the thumbnail putting back.
|
|
///
|
|
/// The other is an image opened with an edit already in its sidecar and left
|
|
/// untouched. Nothing changed, so there is nothing to *re-*render — but the
|
|
/// cached thumbnail came from the file's embedded preview and has never shown
|
|
/// that edit at all. `is_neutral` is what distinguishes it: a graph that does
|
|
/// something, against a thumbnail that shows none of it.
|
|
///
|
|
/// A neutral image nobody touched fails both and costs nothing, which is the
|
|
/// common case on a scroll through a library.
|
|
fn refresh_thumbnail_for(
|
|
window: &AppWindow,
|
|
path: &str,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
library: &Rc<library_ui::LibraryController>,
|
|
) {
|
|
let worth_it = window.get_can_undo()
|
|
|| session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
|
|
if !worth_it {
|
|
return;
|
|
}
|
|
|
|
// The borrow is held across the renders, which is safe here and would not
|
|
// be if this were reachable from a Slint callback that also touches the
|
|
// session — it is not: the only caller is on the way out of the view.
|
|
let mut borrow = session.borrow_mut();
|
|
let Some(open) = borrow.as_mut() else {
|
|
return;
|
|
};
|
|
library_ui::refresh_thumbnail(window, library, path, |edge| open.render_thumbnail(edge));
|
|
}
|
|
|
|
/// Load a stored edit into the open session and refresh the panel.
|
|
///
|
|
/// Returns whether anything was applied, so the caller can skip a redraw for
|
|
/// the common case of a photograph that has never been edited.
|
|
pub fn apply_stored_edit(
|
|
window: &AppWindow,
|
|
sidecar: &Sidecar,
|
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
|
rows: &Rc<slint::VecModel<ParamRow>>,
|
|
) -> bool {
|
|
let Some(version) = sidecar.default_version() else {
|
|
return false;
|
|
};
|
|
|
|
{
|
|
let mut slot = session.borrow_mut();
|
|
let Some(s) = slot.as_mut() else { return false };
|
|
s.apply_version(version);
|
|
}
|
|
|
|
crate::sync_rows(window, rows, session);
|
|
true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wiring
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Push the clipboard's state onto the window.
|
|
///
|
|
/// One function rather than three `set_` calls at each site, because the three
|
|
/// properties have to agree: a summary describing a scope the button is not
|
|
/// using would be worse than no summary.
|
|
pub fn render(
|
|
window: &AppWindow,
|
|
clipboard: &Rc<Clipboard>,
|
|
settings: &Rc<settings_ui::SettingsController>,
|
|
) {
|
|
let scope = scope_for(&settings.snapshot());
|
|
window.set_settings_armed(clipboard.is_armed());
|
|
window.set_settings_summary(clipboard.describe(scope).into());
|
|
// Only worth saying when it is actually true of *this* copy: a clipboard
|
|
// holding no crop loses nothing to the setting, and saying so anyway would
|
|
// train the user to ignore the line.
|
|
window.set_settings_framing_withheld(clipboard.holds_framing() && scope == Scope::Adjustments);
|
|
}
|
|
|
|
/// The develop view's shared state, as this module needs it.
|
|
///
|
|
/// Bundled rather than passed one by one because these four always travel
|
|
/// together — a paste changes the graph, so it must rebuild the panel, redraw
|
|
/// the canvas and know where to save, and a call site holding three of the
|
|
/// four is a call site with a bug in it.
|
|
#[derive(Clone)]
|
|
pub struct Develop {
|
|
pub session: Rc<RefCell<Option<DevelopSession>>>,
|
|
pub rows: Rc<slint::VecModel<ParamRow>>,
|
|
pub redraw: Rc<dyn Fn(&AppWindow)>,
|
|
/// Where the open image's edit is stored.
|
|
pub open: OpenImage,
|
|
}
|
|
|
|
/// Wire copy, paste and batch-paste.
|
|
pub fn wire(
|
|
window: &AppWindow,
|
|
clipboard: Rc<Clipboard>,
|
|
develop: Develop,
|
|
settings: Rc<settings_ui::SettingsController>,
|
|
library: Rc<library_ui::LibraryController>,
|
|
collections: Rc<crate::collections_ui::CollectionsController>,
|
|
) {
|
|
let Develop {
|
|
session,
|
|
rows,
|
|
redraw,
|
|
open,
|
|
} = develop;
|
|
// --- copy ------------------------------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let clipboard = clipboard.clone();
|
|
let session = session.clone();
|
|
let settings = settings.clone();
|
|
window.on_copy_settings(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(s) = session.borrow().as_ref() {
|
|
clipboard.copy_from(s);
|
|
}
|
|
render(&w, &clipboard, &settings);
|
|
});
|
|
}
|
|
|
|
// --- paste onto the open image ---------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let clipboard = clipboard.clone();
|
|
let session = session.clone();
|
|
let settings = settings.clone();
|
|
let rows = rows.clone();
|
|
let redraw = redraw.clone();
|
|
let open = open.clone();
|
|
let library = library.clone();
|
|
window.on_paste_settings(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(preset) = clipboard.peek() else {
|
|
return;
|
|
};
|
|
let scope = scope_for(&settings.snapshot());
|
|
|
|
{
|
|
let mut slot = session.borrow_mut();
|
|
let Some(s) = slot.as_mut() else { return };
|
|
s.apply_settings(&preset, scope);
|
|
}
|
|
|
|
// The sliders are showing the values that just moved, so the panel
|
|
// has to be rebuilt — a paste is the one edit that changes many
|
|
// controls without any of them having been touched.
|
|
crate::sync_rows(&w, &rows, &session);
|
|
redraw(&w);
|
|
|
|
// Saved immediately rather than on the way out. A paste is a
|
|
// deliberate, discrete action, unlike a drag, and the cost of one
|
|
// write is nothing beside the surprise of it not having stuck.
|
|
save_open_edit(&w, &open.borrow(), &session, &library);
|
|
});
|
|
}
|
|
|
|
// --- paste onto the selection ----------------------------------------
|
|
{
|
|
let weak = window.as_weak();
|
|
let clipboard = clipboard.clone();
|
|
let settings = settings.clone();
|
|
let library = library.clone();
|
|
let collections = collections.clone();
|
|
window.on_paste_settings_to_selection(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(preset) = clipboard.peek() else {
|
|
return;
|
|
};
|
|
let scope = scope_for(&settings.snapshot());
|
|
library_ui::paste_settings_to_selection(
|
|
&w,
|
|
&library,
|
|
&collections.selected(),
|
|
&preset,
|
|
scope,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Seconds since the epoch, or zero if the clock is before it.
|
|
///
|
|
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
|
|
/// which resolves on `revision` first anyway (FR-NC-9), and refusing to save
|
|
/// because a clock is unset would be far worse.
|
|
fn now_secs() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_pipeline::EditGraph;
|
|
|
|
fn tempdir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"dr-presets-test-{name}-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn edited() -> EditGraph {
|
|
use dr_pipeline::ops::exposure;
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
|
g
|
|
}
|
|
|
|
#[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).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);
|
|
|
|
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).unwrap();
|
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything).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).unwrap();
|
|
let first = load_local(&image)
|
|
.unwrap()
|
|
.default_version()
|
|
.unwrap()
|
|
.revision;
|
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything).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).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).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,
|
|
)
|
|
.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");
|
|
}
|
|
}
|