//! 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>; /// 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>, } impl Clipboard { pub fn new() -> Rc { 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 { 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 { 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 { 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) { let scope = scope_for(&settings.snapshot()); let kinds: Vec = 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, clipboard: Rc, ) { 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::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 { 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>>, library: &Rc, ) { // 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>>, library: &Rc, ) { let worth_it = window.global::().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>>, rows: &Rc>, ) -> 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, settings: &Rc, ) { let scope = scope_for(&settings.snapshot()); let transfer = window.global::(); 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>>, pub rows: Rc>, pub redraw: Rc, /// Where the open image's edit is stored. pub open: OpenImage, } /// Wire copy, paste and batch-paste. pub fn wire( window: &AppWindow, clipboard: Rc, develop: Develop, settings: Rc, library: Rc, collections: Rc, ) { 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::().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::().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, /// 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>, } impl NamedPresets { /// Load the library from its usual place. pub fn open() -> Rc { 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::at(PresetStore::open_at(path)) } fn at(store: PresetStore) -> Rc { // 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 { 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 { 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 { 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 { 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, 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, } struct Folder { key: String, label: String, folders: Vec, 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::() } } 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 { 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 { fn visit( folder: &Folder, depth: i32, open: &dyn Fn(&str) -> bool, rows: &mut Vec, ) { 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) { 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, develop: Develop, settings: Rc, library: Rc, collections: Rc, ) { 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::().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::().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, 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#" +0.5 "#, ) .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#""#, ) .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}"); } }