Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between images, and batch-apply to a selection. The last two have been here for a while; this is the first. The format is the sidecar's, deliberately. A preset *is* the non-default half of a version, so the lines are the same lines keyed the same way, which makes the two files diffable against each other and lets someone debugging an edit paste a block from one into the other. One file rather than one per preset: a preset per file makes the name a path, and every name then has to survive a filesystem — a `/` becomes a directory, a name differing only in case collides on one platform and not another, and renaming becomes two operations that can half-fail. As a key in a document it is none of those. Unknown *parameters* needed no machinery. `Preset` already holds whatever keys it is given and resolves them against the descriptors only at apply time, so one written by a newer build survives by being stored. Only lines that are not `op.param = float` at all are preserved verbatim, which is the sidecar's version-skew promise made here too. Applying is the paste path with a different source, so a preset reaches a selection through the sidecar read-modify-write that was already there: no graph, no decode, no GPU, forty files or one. Two smaller decisions worth the record. A library that fails to parse is held empty in memory and *not* written back over — settings regenerate themselves and this is work, so a parse failure must not be the moment it is destroyed. And every save persists immediately and rolls the in-memory copy back if the write fails, so the sheet never lists a preset the file does not have. The grid's "Presets" button is gated on the selection alone, unlike the "Paste to 40" beside it. That button needs a clipboard armed this session; the preset list is whatever was saved last month, and hiding it behind an unrelated action is what makes a feature only its author knows about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,7 @@ pub use operation::{
|
|||||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
||||||
};
|
};
|
||||||
pub use preset::{Preset, Scope};
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
||||||
pub use sidecar::{Sidecar, Version};
|
pub use sidecar::{Sidecar, Version};
|
||||||
pub use spot::{Spot, SpotMode, SpotSet};
|
pub use spot::{Spot, SpotMode, SpotSet};
|
||||||
pub use state::{EditState, FilmRebake, FilmRef};
|
pub use state::{EditState, FilmRebake, FilmRef};
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
//! is the whole claim the action makes.
|
//! is the whole claim the action makes.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use crate::descriptor::{OpId, ParamId};
|
use crate::descriptor::{OpId, ParamId};
|
||||||
use crate::graph::EditGraph;
|
use crate::graph::EditGraph;
|
||||||
@@ -240,6 +242,328 @@ pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId,
|
|||||||
Some((cap.id, p.id))
|
Some((cap.id, p.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Named presets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Format version of a preset library file.
|
||||||
|
///
|
||||||
|
/// Present where `settings.json` has no version field, and the difference is
|
||||||
|
/// not an inconsistency. Settings are a flat bag of `#[serde(default)]`
|
||||||
|
/// fields, so an older file is *missing* keys rather than wrong about them and
|
||||||
|
/// additive change needs no version. This file has structure — blocks, and a
|
||||||
|
/// name carried in a block header — and a change to what a block *means* is
|
||||||
|
/// not something a reader can detect by noticing an absent key.
|
||||||
|
pub const LIBRARY_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// The file extension for a DarkRoom preset library.
|
||||||
|
pub const LIBRARY_EXTENSION: &str = "drpl";
|
||||||
|
|
||||||
|
/// Why a name was refused.
|
||||||
|
///
|
||||||
|
/// A closed set rather than a string, so the interface can say something
|
||||||
|
/// specific about each and the message is not written here — this crate
|
||||||
|
/// depends on nothing and has no business holding user-facing prose.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NameError {
|
||||||
|
/// Empty, or nothing but whitespace.
|
||||||
|
Empty,
|
||||||
|
/// Contains a character the block header cannot carry: `[`, `]`, or a
|
||||||
|
/// line break.
|
||||||
|
///
|
||||||
|
/// A round-trip constraint rather than a matter of taste. The header is
|
||||||
|
/// `[preset <name>]`, so a `]` inside the name would make the file parse
|
||||||
|
/// back as a *different* library, and a newline would make it parse back
|
||||||
|
/// as two.
|
||||||
|
Unrepresentable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// A set of named presets, as stored.
|
||||||
|
///
|
||||||
|
/// # Why one file rather than one file per preset
|
||||||
|
///
|
||||||
|
/// A preset per file makes the name a *path*, and every name then has to
|
||||||
|
/// survive a filesystem: a `/` becomes a directory, a name that differs only
|
||||||
|
/// in case collides on one platform and not another, and renaming becomes two
|
||||||
|
/// operations that can half-fail. Here the name is a key in a document, so
|
||||||
|
/// renaming is a map operation, deleting cannot leave an orphan, and the whole
|
||||||
|
/// library is written atomically by the same tmp-and-rename the settings store
|
||||||
|
/// uses.
|
||||||
|
///
|
||||||
|
/// The cost is that the file is rewritten whole on every change. A preset is a
|
||||||
|
/// few dozen floats and a photographer has tens of them, not thousands, so the
|
||||||
|
/// file is kilobytes; the trade would look different at a scale this is not.
|
||||||
|
///
|
||||||
|
/// # Why the sidecar's shape rather than JSON
|
||||||
|
///
|
||||||
|
/// A preset *is* the non-default half of a version (see the module note), so
|
||||||
|
/// the lines here are the lines a sidecar carries, keyed the same way. That
|
||||||
|
/// makes the two files diffable against each other and lets someone debugging
|
||||||
|
/// an edit paste a block from one into the other. It also keeps this crate
|
||||||
|
/// dependency-free, which is the property that lets it be tested without a
|
||||||
|
/// device (ARCH §6.5a).
|
||||||
|
///
|
||||||
|
/// # Ordering
|
||||||
|
///
|
||||||
|
/// By name, so the same library always writes the same bytes and a caller may
|
||||||
|
/// compare content to decide whether a write is needed — the same
|
||||||
|
/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the
|
||||||
|
/// same reason.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct PresetLibrary {
|
||||||
|
presets: BTreeMap<String, Preset>,
|
||||||
|
/// Lines inside a `[preset]` block that were not `op.param = float`.
|
||||||
|
///
|
||||||
|
/// Keyed by preset name and written back verbatim, so a build that
|
||||||
|
/// predates whatever wrote them round-trips the file without discarding
|
||||||
|
/// it. Unknown *parameters* need no such machinery: [`Preset`] holds
|
||||||
|
/// whatever keys it was given and resolves them against the descriptors
|
||||||
|
/// only at apply time, so a parameter this build has never heard of
|
||||||
|
/// survives simply by being stored.
|
||||||
|
unknown: BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetLibrary {
|
||||||
|
/// Whether a name is storable, and why not if it is not.
|
||||||
|
///
|
||||||
|
/// Leading and trailing whitespace is trimmed rather than refused — it is
|
||||||
|
/// almost always a stray keystroke, and refusing it would mean a dialogue
|
||||||
|
/// about a space.
|
||||||
|
pub fn check_name(name: &str) -> Result<String, NameError> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(NameError::Empty);
|
||||||
|
}
|
||||||
|
if name.contains([']', '[', '\n', '\r']) {
|
||||||
|
return Err(NameError::Unrepresentable);
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `preset` under `name`, replacing any preset already there.
|
||||||
|
///
|
||||||
|
/// Returns whether something was replaced.
|
||||||
|
///
|
||||||
|
/// Replacing rather than refusing, with [`Self::contains`] beside it for a
|
||||||
|
/// caller that wants to ask first: whether overwriting needs a
|
||||||
|
/// confirmation is a question about the interface, and answering it here
|
||||||
|
/// would force every caller into the same answer.
|
||||||
|
///
|
||||||
|
/// An *empty* preset is stored like any other. A neutral edit is a real
|
||||||
|
/// thing to save — applying it returns an image to default, which is the
|
||||||
|
/// fastest "undo everything on these forty frames" there is — and the
|
||||||
|
/// module note above is the same argument made about the clipboard.
|
||||||
|
pub fn insert(&mut self, name: &str, preset: Preset) -> Result<bool, NameError> {
|
||||||
|
let name = Self::check_name(name)?;
|
||||||
|
let replaced = self.presets.insert(name, preset).is_some();
|
||||||
|
Ok(replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset stored under `name`.
|
||||||
|
pub fn get(&self, name: &str) -> Option<&Preset> {
|
||||||
|
self.presets.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a preset is stored under `name`.
|
||||||
|
pub fn contains(&self, name: &str) -> bool {
|
||||||
|
self.presets.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the preset stored under `name`, reporting whether there was one.
|
||||||
|
pub fn remove(&mut self, name: &str) -> bool {
|
||||||
|
self.unknown.remove(name);
|
||||||
|
self.presets.remove(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename `from` to `to`.
|
||||||
|
///
|
||||||
|
/// `Ok(false)` means there was nothing called `from` — not an error, since
|
||||||
|
/// the caller may be acting on a list another window has already changed.
|
||||||
|
/// Renaming onto an existing name replaces it, for the same reason
|
||||||
|
/// [`Self::insert`] does.
|
||||||
|
pub fn rename(&mut self, from: &str, to: &str) -> Result<bool, NameError> {
|
||||||
|
let to = Self::check_name(to)?;
|
||||||
|
let Some(preset) = self.presets.remove(from) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if let Some(unknown) = self.unknown.remove(from) {
|
||||||
|
self.unknown.insert(to.clone(), unknown);
|
||||||
|
}
|
||||||
|
self.presets.insert(to, preset);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stored name, in the order they are written.
|
||||||
|
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.presets.keys().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stored preset with its name, in the order they are written.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
|
||||||
|
self.presets.iter().map(|(n, p)| (n.as_str(), p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many presets are stored.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.presets.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether nothing is stored.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.presets.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise to the on-disk form.
|
||||||
|
///
|
||||||
|
/// Deterministic, like the sidecar's: the same library always produces the
|
||||||
|
/// same bytes.
|
||||||
|
pub fn to_text(&self) -> String {
|
||||||
|
let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n");
|
||||||
|
for (name, preset) in &self.presets {
|
||||||
|
let _ = write!(out, "\n[preset {name}]\n");
|
||||||
|
for ((op, param), value) in preset.params() {
|
||||||
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
||||||
|
}
|
||||||
|
for line in self.unknown.get(name).into_iter().flatten() {
|
||||||
|
let _ = writeln!(out, "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the on-disk form.
|
||||||
|
///
|
||||||
|
/// Tolerant on the same terms as the sidecar's parser, and for a weaker
|
||||||
|
/// version of the same reason: a preset library is not the authoritative
|
||||||
|
/// store an edit lives in, but it is still work the user did by hand, and
|
||||||
|
/// one bad line must cost that line rather than the collection. The only
|
||||||
|
/// hard failures are a file that is not a preset library at all and one
|
||||||
|
/// written by a newer build, where continuing would mean guessing.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, LibraryParseError> {
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let header = lines.next().unwrap_or_default().trim();
|
||||||
|
let Some(version) = header.strip_prefix("drpl ") else {
|
||||||
|
return Err(LibraryParseError::NotALibrary);
|
||||||
|
};
|
||||||
|
match version.trim().parse::<u32>() {
|
||||||
|
Ok(v) if v <= LIBRARY_FORMAT_VERSION => {}
|
||||||
|
Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)),
|
||||||
|
Err(_) => return Err(LibraryParseError::NotALibrary),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut library = Self::default();
|
||||||
|
let mut current: Option<String> = None;
|
||||||
|
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(head) = line
|
||||||
|
.strip_prefix("[preset ")
|
||||||
|
.and_then(|l| l.strip_suffix(']'))
|
||||||
|
{
|
||||||
|
if let Some(name) = current.take() {
|
||||||
|
library.presets.insert(name, Preset::from_params(params));
|
||||||
|
params = BTreeMap::new();
|
||||||
|
}
|
||||||
|
// A name the writer should never have produced is dropped
|
||||||
|
// rather than taken: accepting it would mean writing a file
|
||||||
|
// back out that no longer parses as this one.
|
||||||
|
match Self::check_name(head) {
|
||||||
|
Ok(name) => current = Some(name),
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("preset library: unusable preset name {head:?}; skipping");
|
||||||
|
current = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name) = current.clone() else {
|
||||||
|
log::warn!("preset library: line outside any preset: {line}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match line.split_once('=') {
|
||||||
|
Some((key, value)) => {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
match (key.split_once('.'), value.parse::<f32>()) {
|
||||||
|
(Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => {
|
||||||
|
params.insert((op.to_string(), param.to_string()), v);
|
||||||
|
}
|
||||||
|
_ => library
|
||||||
|
.unknown
|
||||||
|
.entry(name)
|
||||||
|
.or_default()
|
||||||
|
.push(line.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => library
|
||||||
|
.unknown
|
||||||
|
.entry(name)
|
||||||
|
.or_default()
|
||||||
|
.push(line.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = current {
|
||||||
|
library.presets.insert(name, Preset::from_params(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A block whose every line was unreadable still produced a preset, and
|
||||||
|
// an unknown block belonging to no preset would be written back into
|
||||||
|
// whichever one happened to sort first. Drop the orphans.
|
||||||
|
library
|
||||||
|
.unknown
|
||||||
|
.retain(|k, _| library.presets.contains_key(k));
|
||||||
|
|
||||||
|
Ok(library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a value the way the sidecar does — no trailing `.0`, no exponent —
|
||||||
|
/// so the two files stay comparable line for line.
|
||||||
|
fn format_value(v: f32) -> String {
|
||||||
|
let mut s = format!("{v:.6}");
|
||||||
|
if s.contains('.') {
|
||||||
|
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||||
|
}
|
||||||
|
if s == "-0" {
|
||||||
|
s = "0".to_string();
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LibraryParseError {
|
||||||
|
/// The header line was missing or not a `drpl` header.
|
||||||
|
NotALibrary,
|
||||||
|
/// Written by a newer build, in a format this one cannot read.
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LibraryParseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotALibrary => f.write_str("not a DarkRoom preset library"),
|
||||||
|
Self::UnsupportedVersion(v) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"preset library format version {v} is newer than this build"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for LibraryParseError {}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -563,4 +887,196 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(excluded, vec![framing::ID.0]);
|
assert_eq!(excluded, vec![framing::ID.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Named presets
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn named() -> PresetLibrary {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
lib.insert("Warm portrait", Preset::capture(&edited()))
|
||||||
|
.unwrap();
|
||||||
|
lib.insert("Neutral", Preset::default()).unwrap();
|
||||||
|
lib
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_library_round_trips_through_its_text_form() {
|
||||||
|
let lib = named();
|
||||||
|
let back = PresetLibrary::parse(&lib.to_text()).unwrap();
|
||||||
|
assert_eq!(back, lib);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_library_always_writes_the_same_bytes() {
|
||||||
|
// What lets a caller skip a write by comparing content. Built in the
|
||||||
|
// opposite order to `named()` so insertion order cannot be what makes
|
||||||
|
// this pass.
|
||||||
|
let mut other = PresetLibrary::default();
|
||||||
|
other.insert("Neutral", Preset::default()).unwrap();
|
||||||
|
other
|
||||||
|
.insert("Warm portrait", Preset::capture(&edited()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(other.to_text(), named().to_text());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
|
||||||
|
// The empty preset is the "clear these forty frames" action, so it has
|
||||||
|
// to be a real entry rather than an absence — and a block with no
|
||||||
|
// lines under it has to parse back as a preset rather than vanish.
|
||||||
|
let back = PresetLibrary::parse(&named().to_text()).unwrap();
|
||||||
|
assert_eq!(back.get("Neutral"), Some(&Preset::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_line_costs_that_line_and_not_the_library() {
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
|
||||||
|
this line is not a setting\nsaturation.amount = not a number\n"
|
||||||
|
);
|
||||||
|
let lib = PresetLibrary::parse(&text).unwrap();
|
||||||
|
let preset = lib.get("Keep").expect("the preset survived");
|
||||||
|
assert_eq!(
|
||||||
|
preset.params().get(&("exposure".into(), "exposure".into())),
|
||||||
|
Some(&0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_this_build_cannot_read_are_written_back_untouched() {
|
||||||
|
// The sidecar's version-skew promise, applied here: a build running
|
||||||
|
// behind must not silently strip what a newer one wrote.
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\
|
||||||
|
something_new_entirely\n"
|
||||||
|
);
|
||||||
|
let out = PresetLibrary::parse(&text).unwrap().to_text();
|
||||||
|
assert!(out.contains("something_new_entirely"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_parameter_survives_without_any_machinery_for_it() {
|
||||||
|
// `Preset` stores whatever keys it is given and resolves them against
|
||||||
|
// the descriptors only at apply time, so a parameter from a newer
|
||||||
|
// build needs no preservation path of its own.
|
||||||
|
let text = format!(
|
||||||
|
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n"
|
||||||
|
);
|
||||||
|
let out = PresetLibrary::parse(&text).unwrap().to_text();
|
||||||
|
assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() {
|
||||||
|
let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1);
|
||||||
|
assert_eq!(
|
||||||
|
PresetLibrary::parse(&text),
|
||||||
|
Err(LibraryParseError::UnsupportedVersion(
|
||||||
|
LIBRARY_FORMAT_VERSION + 1
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn something_that_is_not_a_preset_library_is_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
PresetLibrary::parse("drsc 1\n\n[version abc]\n"),
|
||||||
|
Err(LibraryParseError::NotALibrary)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PresetLibrary::parse(""),
|
||||||
|
Err(LibraryParseError::NotALibrary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_that_would_not_parse_back_is_refused() {
|
||||||
|
// Round-trip safety, not taste: a `]` would close the header early and
|
||||||
|
// the file would read back as a different library.
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
assert_eq!(
|
||||||
|
lib.insert("bracket] inside", Preset::default()),
|
||||||
|
Err(NameError::Unrepresentable)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lib.insert("two\nlines", Preset::default()),
|
||||||
|
Err(NameError::Unrepresentable)
|
||||||
|
);
|
||||||
|
assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surrounding_whitespace_is_trimmed_rather_than_refused() {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
lib.insert(" Warm ", Preset::default()).unwrap();
|
||||||
|
assert!(lib.contains("Warm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saving_over_a_name_replaces_it_and_says_so() {
|
||||||
|
let mut lib = named();
|
||||||
|
assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true));
|
||||||
|
assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false));
|
||||||
|
assert_eq!(lib.get("Warm portrait"), Some(&Preset::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renaming_moves_the_preset_and_leaves_nothing_behind() {
|
||||||
|
let mut lib = named();
|
||||||
|
let before = lib.get("Warm portrait").cloned().unwrap();
|
||||||
|
assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true));
|
||||||
|
assert!(!lib.contains("Warm portrait"));
|
||||||
|
assert_eq!(lib.get("Cool portrait"), Some(&before));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renaming_something_that_is_gone_is_not_an_error() {
|
||||||
|
// Another window may have deleted it since this list was drawn.
|
||||||
|
let mut lib = named();
|
||||||
|
assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_reports_whether_there_was_anything_to_delete() {
|
||||||
|
let mut lib = named();
|
||||||
|
assert!(lib.remove("Neutral"));
|
||||||
|
assert!(!lib.remove("Neutral"));
|
||||||
|
assert_eq!(lib.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_preset_applies_exactly_as_a_pasted_one_does() {
|
||||||
|
// The whole point of sharing one representation: a named preset is not
|
||||||
|
// a second kind of thing with a second apply path.
|
||||||
|
let lib = named();
|
||||||
|
let stored = PresetLibrary::parse(&lib.to_text()).unwrap();
|
||||||
|
let preset = stored.get("Warm portrait").unwrap();
|
||||||
|
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
preset.apply(&mut target, Scope::Adjustments);
|
||||||
|
assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
||||||
|
// The target keeps its own framing on the default scope.
|
||||||
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_preset_amends_a_sidecar_without_a_graph() {
|
||||||
|
// The batch path: applying to forty images must not build forty
|
||||||
|
// graphs, so this is the call the library's batch apply makes.
|
||||||
|
let lib = named();
|
||||||
|
let preset = lib.get("Warm portrait").unwrap();
|
||||||
|
let mut params: BTreeMap<(String, String), f32> = BTreeMap::new();
|
||||||
|
params.insert(("framing".into(), "angle".into()), 5.0);
|
||||||
|
params.insert(("exposure".into(), "exposure".into()), -1.0);
|
||||||
|
|
||||||
|
preset.amend(&mut params, Scope::Adjustments);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
params.get(&("exposure".into(), "exposure".into())),
|
||||||
|
Some(&0.75)
|
||||||
|
);
|
||||||
|
// Out of scope, so the target's own crop is untouched.
|
||||||
|
assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-40
File diff suppressed because one or more lines are too long
@@ -39,6 +39,7 @@ mod library_ui;
|
|||||||
mod live_style;
|
mod live_style;
|
||||||
mod masks_ui;
|
mod masks_ui;
|
||||||
mod net_runtime;
|
mod net_runtime;
|
||||||
|
mod preset_store;
|
||||||
mod presets;
|
mod presets;
|
||||||
mod remote;
|
mod remote;
|
||||||
mod segmentation;
|
mod segmentation;
|
||||||
@@ -2021,6 +2022,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
collections.clone(),
|
collections.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The saved half of the same requirement, wired from the same bundle:
|
||||||
|
// applying a named preset to the open image is the paste path with a
|
||||||
|
// different source.
|
||||||
|
presets::wire_named(
|
||||||
|
&window,
|
||||||
|
presets::NamedPresets::open(),
|
||||||
|
presets::Develop {
|
||||||
|
session: session.clone(),
|
||||||
|
rows: rows.clone(),
|
||||||
|
redraw: redraw.clone(),
|
||||||
|
open: open_image.clone(),
|
||||||
|
},
|
||||||
|
settings.clone(),
|
||||||
|
library.clone(),
|
||||||
|
collections.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Close the knot left open beside `open_from_library`: the grid's
|
// Close the knot left open beside `open_from_library`: the grid's
|
||||||
// "‹ Library" button was wired before there was a session to save.
|
// "‹ Library" button was wired before there was a session to save.
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1
|
||||||
|
//! Reads and writes the named preset library beside the other config.
|
||||||
|
//!
|
||||||
|
//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and
|
||||||
|
//! separate from it for the reason that one is: two files, two lifetimes.
|
||||||
|
//! Resetting preferences must not destroy a photographer's presets, and a
|
||||||
|
//! preset library is the one file here that represents work rather than
|
||||||
|
//! configuration — it is what someone would carry to another machine.
|
||||||
|
//!
|
||||||
|
//! # Why the library is loaded whole and saved whole
|
||||||
|
//!
|
||||||
|
//! There is no incremental path. The document is kilobytes (see
|
||||||
|
//! [`PresetLibrary`]), the caller is holding the copy the user just edited,
|
||||||
|
//! and a merge would let a preset the window has not drawn yet resurrect
|
||||||
|
//! after a delete. The same argument the settings store makes about fields,
|
||||||
|
//! made about entries.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use dr_pipeline::PresetLibrary;
|
||||||
|
|
||||||
|
/// Loads and saves the named preset library.
|
||||||
|
pub struct PresetStore {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresetStore {
|
||||||
|
/// Open the store at the platform config location.
|
||||||
|
///
|
||||||
|
/// Linux: `$XDG_CONFIG_HOME/darkroom/presets.drpl`, falling back to
|
||||||
|
/// `~/.config` — the same resolution `SettingsStore` does, so the files sit
|
||||||
|
/// together and a user backing up one takes all of them.
|
||||||
|
pub fn open() -> Self {
|
||||||
|
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
|
||||||
|
})
|
||||||
|
.join("darkroom");
|
||||||
|
Self::open_at(dir.join(format!(
|
||||||
|
"presets.{}",
|
||||||
|
dr_pipeline::preset::LIBRARY_EXTENSION
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open at an explicit path — for tests, and for a non-default location.
|
||||||
|
pub fn open_at(path: PathBuf) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored library, or an empty one.
|
||||||
|
///
|
||||||
|
/// A missing file is a first run. An unparseable one is answered with an
|
||||||
|
/// empty library rather than an error, on the same reasoning the settings
|
||||||
|
/// store gives — the alternative is an app that will not start until the
|
||||||
|
/// user hand-edits a file.
|
||||||
|
///
|
||||||
|
/// The difference worth stating: settings are regenerated on the next
|
||||||
|
/// save, where a preset library is *work*, and rewriting it whole would
|
||||||
|
/// destroy whatever was in there. So a library that failed to parse is
|
||||||
|
/// held empty in memory and **not** written back over until the user saves
|
||||||
|
/// a preset, at which point they have chosen to. Nothing here deletes the
|
||||||
|
/// file, and the warning names the path so it can be recovered by hand.
|
||||||
|
pub fn load(&self) -> PresetLibrary {
|
||||||
|
match std::fs::read_to_string(&self.path) {
|
||||||
|
Ok(text) => match PresetLibrary::parse(&text) {
|
||||||
|
Ok(library) => library,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"{} is not a readable preset library ({e}); \
|
||||||
|
starting empty, the file is left alone",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
PresetLibrary::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("reading {}: {e}; starting empty", self.path.display());
|
||||||
|
PresetLibrary::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the library, replacing whatever was there.
|
||||||
|
pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write and rename, so an interrupted save cannot truncate the
|
||||||
|
// existing file — the same discipline the settings and session stores
|
||||||
|
// use, and it matters more here because what would be truncated is
|
||||||
|
// every preset the user has ever made rather than a set of
|
||||||
|
// preferences that rebuild themselves.
|
||||||
|
let tmp = self.path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp, library.to_text())?;
|
||||||
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum PresetStoreError {
|
||||||
|
#[error("preset library io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use dr_pipeline::Preset;
|
||||||
|
|
||||||
|
/// The same hand-rolled temp directory the settings store's tests use —
|
||||||
|
/// unique per process and thread, so a parallel run cannot collide.
|
||||||
|
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 store(name: &str) -> (PresetStore, PathBuf) {
|
||||||
|
let dir = tempdir(name);
|
||||||
|
(
|
||||||
|
PresetStore::open_at(dir.join("nested").join("presets.drpl")),
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library() -> PresetLibrary {
|
||||||
|
let mut lib = PresetLibrary::default();
|
||||||
|
let mut params = std::collections::BTreeMap::new();
|
||||||
|
params.insert(("exposure".to_string(), "exposure".to_string()), 0.75);
|
||||||
|
lib.insert("Warm", Preset::from_params(params)).unwrap();
|
||||||
|
lib.insert("Neutral", Preset::default()).unwrap();
|
||||||
|
lib
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_library_round_trips_through_the_store() {
|
||||||
|
let (store, _dir) = store("a-library-round-trips-through-the-store");
|
||||||
|
store.save(&library()).unwrap();
|
||||||
|
assert_eq!(store.load(), library());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_first_run_loads_an_empty_library() {
|
||||||
|
let (store, _dir) = store("a-first-run-loads-an-empty-library");
|
||||||
|
assert!(!store.path().exists());
|
||||||
|
assert!(store.load().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saving_creates_the_config_directory() {
|
||||||
|
let (store, _dir) = store("saving-creates-the-config-directory");
|
||||||
|
store.save(&library()).unwrap();
|
||||||
|
assert!(store.path().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_file_is_left_alone_rather_than_overwritten() {
|
||||||
|
// The difference from the settings store, and the reason this test
|
||||||
|
// exists: what is on disk is work, so a parse failure must not be the
|
||||||
|
// moment it is destroyed.
|
||||||
|
let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten");
|
||||||
|
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(store.path(), "this is not a preset library").unwrap();
|
||||||
|
|
||||||
|
assert!(store.load().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(store.path()).unwrap(),
|
||||||
|
"this is not a preset library"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_preset_survives_a_save_and_load() {
|
||||||
|
// It is a real entry, not an absence — see `PresetLibrary::insert`.
|
||||||
|
let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load");
|
||||||
|
store.save(&library()).unwrap();
|
||||||
|
assert_eq!(store.load().get("Neutral"), Some(&Preset::default()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+421
-6
@@ -35,10 +35,11 @@ use std::cell::RefCell;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use dr_pipeline::{Preset, Scope, Sidecar};
|
use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar};
|
||||||
use slint::ComponentHandle;
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
use crate::develop::DevelopSession;
|
use crate::develop::DevelopSession;
|
||||||
|
use crate::preset_store::PresetStore;
|
||||||
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
|
use crate::{library, library_ui, settings_ui, AppWindow, ParamRow};
|
||||||
|
|
||||||
/// Where the develop view's current edit is stored.
|
/// Where the develop view's current edit is stored.
|
||||||
@@ -121,11 +122,21 @@ impl Clipboard {
|
|||||||
let Some(preset) = self.preset.borrow().clone() else {
|
let Some(preset) = self.preset.borrow().clone() else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
match preset.op_count(scope) {
|
describe(&preset, scope)
|
||||||
0 => "Neutral".to_string(),
|
}
|
||||||
1 => "1 adjustment".to_string(),
|
}
|
||||||
n => format!("{n} adjustments"),
|
|
||||||
}
|
/// 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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +539,308 @@ pub fn wire(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Named presets (FR-DEV-6)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The saved preset library, and the file it lives in.
|
||||||
|
///
|
||||||
|
/// # Why the library is held in memory as well as on disk
|
||||||
|
///
|
||||||
|
/// Every change writes the whole file (see [`PresetStore`]), so the in-memory
|
||||||
|
/// copy is what the sheet is drawn from and what the next edit is applied to.
|
||||||
|
/// Reading the file back after each change would be the same bytes and one
|
||||||
|
/// more chance for a failed read to empty a list the user is looking at.
|
||||||
|
///
|
||||||
|
/// # A failed save is reported, not swallowed
|
||||||
|
///
|
||||||
|
/// The clipboard cannot fail — it is memory. This can: a full disk, a config
|
||||||
|
/// directory that is not writable. Losing a preset the user just named, with
|
||||||
|
/// the sheet cheerfully listing it, would be discovered at the worst possible
|
||||||
|
/// moment, so the write's result reaches the window.
|
||||||
|
pub struct NamedPresets {
|
||||||
|
store: PresetStore,
|
||||||
|
library: RefCell<PresetLibrary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NamedPresets {
|
||||||
|
/// Load the library from its usual place.
|
||||||
|
pub fn open() -> Rc<Self> {
|
||||||
|
Self::at(PresetStore::open())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from an explicit store — for tests, and for a non-default location.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn open_at(path: PathBuf) -> Rc<Self> {
|
||||||
|
Self::at(PresetStore::open_at(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(store: PresetStore) -> Rc<Self> {
|
||||||
|
let library = RefCell::new(store.load());
|
||||||
|
Rc::new(Self { store, library })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored names, in the order they are written.
|
||||||
|
pub fn names(&self) -> Vec<String> {
|
||||||
|
self.library.borrow().names().map(String::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preset stored under `name`.
|
||||||
|
pub fn get(&self, name: &str) -> Option<Preset> {
|
||||||
|
self.library.borrow().get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `preset` under `name` and persist.
|
||||||
|
///
|
||||||
|
/// The in-memory library is updated first and rolled back if the write
|
||||||
|
/// fails, so what the sheet lists is always what is on disk. The
|
||||||
|
/// alternative — writing first — would mean holding a preset the file does
|
||||||
|
/// not have on every failure path.
|
||||||
|
fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> {
|
||||||
|
let previous = {
|
||||||
|
let mut library = self.library.borrow_mut();
|
||||||
|
let existing = library.get(name.trim()).cloned();
|
||||||
|
library.insert(name, preset).map_err(SaveError::Name)?;
|
||||||
|
existing
|
||||||
|
};
|
||||||
|
self.persist(|library| match previous {
|
||||||
|
Some(p) => {
|
||||||
|
let _ = library.insert(name, p);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
library.remove(name.trim());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the library, undoing the in-memory change if the write fails.
|
||||||
|
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
|
||||||
|
let result = self.store.save(&self.library.borrow());
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
rollback(&mut self.library.borrow_mut());
|
||||||
|
// Named, the way the settings page names its file: "could not
|
||||||
|
// save" without saying where leaves the user nothing to check
|
||||||
|
// and nothing to fix.
|
||||||
|
Err(SaveError::Write(format!(
|
||||||
|
"{}: {e}",
|
||||||
|
self.store.path().display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a preset could not be saved.
|
||||||
|
enum SaveError {
|
||||||
|
/// The name itself was refused.
|
||||||
|
Name(NameError),
|
||||||
|
/// The library could not be written.
|
||||||
|
Write(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveError {
|
||||||
|
/// What to put in front of the user.
|
||||||
|
///
|
||||||
|
/// The prose lives here rather than in `dr-pipeline`, which depends on
|
||||||
|
/// nothing and has no business holding user-facing strings.
|
||||||
|
fn message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Name(NameError::Empty) => "Give the preset a name.".to_string(),
|
||||||
|
Self::Name(NameError::Unrepresentable) => {
|
||||||
|
"A preset name cannot contain brackets or line breaks.".to_string()
|
||||||
|
}
|
||||||
|
Self::Write(e) => format!("Could not save presets: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the stored names onto the window.
|
||||||
|
pub fn render_named(window: &AppWindow, named: &Rc<NamedPresets>) {
|
||||||
|
let names: Vec<slint::SharedString> = named.names().into_iter().map(Into::into).collect();
|
||||||
|
window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire saving, applying, renaming and deleting named presets.
|
||||||
|
///
|
||||||
|
/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the
|
||||||
|
/// same reason: applying a preset to the open image changes the graph, so it
|
||||||
|
/// has to rebuild the panel, redraw the canvas and know where to save.
|
||||||
|
pub fn wire_named(
|
||||||
|
window: &AppWindow,
|
||||||
|
named: Rc<NamedPresets>,
|
||||||
|
develop: Develop,
|
||||||
|
settings: Rc<settings_ui::SettingsController>,
|
||||||
|
library: Rc<library_ui::LibraryController>,
|
||||||
|
collections: Rc<crate::collections_ui::CollectionsController>,
|
||||||
|
) {
|
||||||
|
let Develop {
|
||||||
|
session,
|
||||||
|
rows,
|
||||||
|
redraw,
|
||||||
|
open,
|
||||||
|
} = develop;
|
||||||
|
|
||||||
|
render_named(window, &named);
|
||||||
|
|
||||||
|
// --- save the open edit under a name ---------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
let session = session.clone();
|
||||||
|
window.on_save_preset(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
|
||||||
|
w.set_preset_name_error("Open a photograph first.".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Captured at full scope, exactly as a copy is: the scope is a
|
||||||
|
// decision about applying, and a preset that had already discarded
|
||||||
|
// the crop could never grow it back (see `Preset::capture`).
|
||||||
|
match named.insert(&name, preset) {
|
||||||
|
Ok(()) => {
|
||||||
|
w.set_preset_name_error(Default::default());
|
||||||
|
render_named(&w, &named);
|
||||||
|
}
|
||||||
|
Err(e) => w.set_preset_name_error(e.message().into()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal the user has started correcting is stale, and a message that
|
||||||
|
// outlives its cause is one the user learns to ignore.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.on_preset_name_edited(move |_| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_preset_name_error(Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- what saving would capture ----------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
window.on_presets_opened(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// At the scope a save would use, which is full: a preset keeps
|
||||||
|
// the framing it was captured with and drops it at apply time.
|
||||||
|
let summary = session
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| describe(&s.copy_settings(), Scope::Everything))
|
||||||
|
.unwrap_or_default();
|
||||||
|
w.set_preset_capture_summary(summary.into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- apply ------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// One callback for both targets. Which one is meant is not a guess: the
|
||||||
|
// sheet was opened from a view that set `preset-apply-count`, and the
|
||||||
|
// label the user just read said "Applies to 12 selected photographs" or
|
||||||
|
// said nothing. Deciding here from the same number keeps the promise.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
let settings = settings.clone();
|
||||||
|
let library = library.clone();
|
||||||
|
let collections = collections.clone();
|
||||||
|
let session = session.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let open = open.clone();
|
||||||
|
window.on_apply_preset(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(preset) = named.get(&name) else {
|
||||||
|
// Another window may have deleted it since this list was drawn.
|
||||||
|
render_named(&w, &named);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let scope = scope_for(&settings.snapshot());
|
||||||
|
|
||||||
|
if w.get_preset_apply_count() > 0 {
|
||||||
|
library_ui::paste_settings_to_selection(
|
||||||
|
&w,
|
||||||
|
&library,
|
||||||
|
&collections.selected(),
|
||||||
|
&preset,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
let mut slot = session.borrow_mut();
|
||||||
|
let Some(s) = slot.as_mut() else { return };
|
||||||
|
s.apply_settings(&preset, scope);
|
||||||
|
}
|
||||||
|
// The same three steps a paste takes, for the same reasons:
|
||||||
|
// many controls moved without any of them being touched, and
|
||||||
|
// a deliberate discrete action is saved immediately.
|
||||||
|
crate::sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
save_open_edit(&w, &open.borrow(), &session, &library);
|
||||||
|
}
|
||||||
|
|
||||||
|
w.set_presets_open(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rename -----------------------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
window.on_rename_preset(move |from, to| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let renamed = {
|
||||||
|
let mut library = named.library.borrow_mut();
|
||||||
|
library.rename(&from, &to)
|
||||||
|
};
|
||||||
|
match renamed {
|
||||||
|
Ok(_) => {
|
||||||
|
// Nothing to roll back to on a failed write beyond the
|
||||||
|
// name it had, which is what this restores.
|
||||||
|
let from = from.to_string();
|
||||||
|
let to = to.to_string();
|
||||||
|
if let Err(e) = named.persist(move |library| {
|
||||||
|
let _ = library.rename(&to, &from);
|
||||||
|
}) {
|
||||||
|
w.set_preset_name_error(e.message().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()),
|
||||||
|
}
|
||||||
|
render_named(&w, &named);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete -----------------------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let named = named.clone();
|
||||||
|
window.on_delete_preset(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let removed = {
|
||||||
|
let mut library = named.library.borrow_mut();
|
||||||
|
let previous = library.get(&name).cloned();
|
||||||
|
library.remove(&name);
|
||||||
|
previous
|
||||||
|
};
|
||||||
|
if let Some(previous) = removed {
|
||||||
|
let name = name.to_string();
|
||||||
|
if let Err(e) = named.persist(move |library| {
|
||||||
|
let _ = library.insert(&name, previous);
|
||||||
|
}) {
|
||||||
|
w.set_preset_name_error(e.message().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render_named(&w, &named);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Seconds since the epoch, or zero if the clock is before it.
|
/// 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,
|
/// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge,
|
||||||
@@ -804,4 +1117,106 @@ mod tests {
|
|||||||
assert!(clipboard.is_armed());
|
assert!(clipboard.is_armed());
|
||||||
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
|
assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Named presets
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
|
||||||
|
let dir = tempdir(name);
|
||||||
|
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_preset_is_on_disk_before_the_call_returns() {
|
||||||
|
// Not on the way out, and not on a timer: a preset the user named and
|
||||||
|
// then lost to a crash is the one failure this feature cannot have.
|
||||||
|
let (presets, dir) = named("saved-immediately");
|
||||||
|
presets
|
||||||
|
.insert("Warm", Preset::capture(&edited()))
|
||||||
|
.ok()
|
||||||
|
.expect("saved");
|
||||||
|
|
||||||
|
let reloaded = NamedPresets::open_at(dir.join("presets.drpl"));
|
||||||
|
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_preset_carries_the_edit_it_captured() {
|
||||||
|
let (presets, _dir) = named("carries-the-edit");
|
||||||
|
presets.insert("Warm", Preset::capture(&edited())).ok();
|
||||||
|
|
||||||
|
let preset = presets.get("Warm").expect("stored");
|
||||||
|
let mut target = EditGraph::default_chain();
|
||||||
|
preset.apply(&mut target, Scope::Adjustments);
|
||||||
|
assert_eq!(
|
||||||
|
target.param(
|
||||||
|
dr_pipeline::ops::exposure::ID,
|
||||||
|
dr_pipeline::ops::exposure::EXPOSURE
|
||||||
|
),
|
||||||
|
Some(1.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() {
|
||||||
|
let (presets, _dir) = named("refused-name");
|
||||||
|
assert!(presets.insert("", Preset::default()).is_err());
|
||||||
|
assert!(presets.insert("bracket]", Preset::default()).is_err());
|
||||||
|
assert!(presets.names().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() {
|
||||||
|
// The rollback. A sheet listing a preset the file does not have is a
|
||||||
|
// loss the user discovers later, at the moment they reach for it.
|
||||||
|
let dir = tempdir("failed-write");
|
||||||
|
// A *file* where the store wants a directory, so `create_dir_all`
|
||||||
|
// fails and the save cannot succeed.
|
||||||
|
let blocked = dir.join("blocked");
|
||||||
|
std::fs::write(&blocked, b"not a directory").unwrap();
|
||||||
|
|
||||||
|
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
|
||||||
|
assert!(presets.insert("Warm", Preset::default()).is_err());
|
||||||
|
assert!(
|
||||||
|
presets.names().is_empty(),
|
||||||
|
"the failed save left a preset behind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_overwrite_puts_the_original_back() {
|
||||||
|
// The other half of the rollback, and the one that loses work if it is
|
||||||
|
// wrong: overwriting is destructive, so a failed overwrite has to
|
||||||
|
// restore what was there rather than leave the name holding the new
|
||||||
|
// value the file never received.
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tempdir("failed-overwrite");
|
||||||
|
let path = dir.join("presets.drpl");
|
||||||
|
let presets = NamedPresets::open_at(path.clone());
|
||||||
|
presets.insert("Warm", Preset::capture(&edited())).ok();
|
||||||
|
let original = presets.get("Warm").expect("stored");
|
||||||
|
|
||||||
|
// The directory exists, so `create_dir_all` still succeeds and it is
|
||||||
|
// the write of the temporary file that fails — which is the path a
|
||||||
|
// full disk takes.
|
||||||
|
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
|
||||||
|
perms.set_mode(0o500);
|
||||||
|
std::fs::set_permissions(&dir, perms.clone()).unwrap();
|
||||||
|
|
||||||
|
let failed = presets.insert("Warm", Preset::default()).is_err();
|
||||||
|
|
||||||
|
// Restore the permissions before asserting, so a failure here does not
|
||||||
|
// leave an undeletable directory behind for the next run.
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
std::fs::set_permissions(&dir, perms).unwrap();
|
||||||
|
|
||||||
|
assert!(failed, "the write should have failed");
|
||||||
|
assert_eq!(
|
||||||
|
presets.get("Warm"),
|
||||||
|
Some(original),
|
||||||
|
"the failed overwrite kept the new value"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -733,6 +733,10 @@ export component TransferPanel inherits VerticalLayout {
|
|||||||
|
|
||||||
callback copy();
|
callback copy();
|
||||||
callback paste();
|
callback paste();
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Open the named-preset sheet. Beside copy and paste because it is the
|
||||||
|
/// same thought given a name — this edit, kept.
|
||||||
|
callback open-presets();
|
||||||
|
|
||||||
/// TRACES: FR-UI-2
|
/// TRACES: FR-UI-2
|
||||||
/// How wide this panel has to be before it starts clipping itself — the
|
/// How wide this panel has to be before it starts clipping itself — the
|
||||||
@@ -772,6 +776,16 @@ export component TransferPanel inherits VerticalLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The saved half. On its own row rather than a third of the one above,
|
||||||
|
// because copy and paste are a pair — one arms the other — and a button
|
||||||
|
// that does neither sitting between them would read as part of that pair.
|
||||||
|
Button {
|
||||||
|
text: "Presets…";
|
||||||
|
enabled: root.enabled;
|
||||||
|
clicked => { root.open-presets(); }
|
||||||
|
}
|
||||||
|
|
||||||
if root.armed: Caption {
|
if root.armed: Caption {
|
||||||
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
|
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi
|
|||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
|
import { PresetSheet } from "presets.slint";
|
||||||
import { SettingsPage } from "settings.slint";
|
import { SettingsPage } from "settings.slint";
|
||||||
import { ImportPage } from "import.slint";
|
import { ImportPage } from "import.slint";
|
||||||
import { StatusBar, InfoPanel } from "develop.slint";
|
import { StatusBar, InfoPanel } from "develop.slint";
|
||||||
@@ -639,6 +640,35 @@ export component AppWindow inherits Window {
|
|||||||
/// Apply the clipboard to every selected image in the grid.
|
/// Apply the clipboard to every selected image in the grid.
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
|
|
||||||
|
// --- named presets (FR-DEV-6) ---
|
||||||
|
//
|
||||||
|
// The saved half of the same requirement. On the window rather than in a
|
||||||
|
// view for the reason the clipboard is: a preset is saved in develop,
|
||||||
|
// where there is an edit to capture, and applied most often in the grid,
|
||||||
|
// where there is a selection to apply it to.
|
||||||
|
in-out property <bool> presets-open: false;
|
||||||
|
in property <[string]> preset-names;
|
||||||
|
/// Whether there is an edit in hand to save, set by whichever view opened
|
||||||
|
/// the sheet. `in-out` because that is where the answer is known.
|
||||||
|
in-out property <bool> preset-can-save: false;
|
||||||
|
/// What applying would act on: 0 is the open photograph, higher is that
|
||||||
|
/// many selected. Set at open, and read back by Rust when one is picked —
|
||||||
|
/// so the action matches the count the user read on the way in.
|
||||||
|
in-out property <int> preset-apply-count: 0;
|
||||||
|
/// What saving would capture, from the routine the clipboard summary uses.
|
||||||
|
in property <string> preset-capture-summary;
|
||||||
|
/// Why the last name was refused, cleared by the next keystroke.
|
||||||
|
in property <string> preset-name-error;
|
||||||
|
callback save-preset(string);
|
||||||
|
callback apply-preset(string);
|
||||||
|
callback rename-preset(string, string);
|
||||||
|
callback delete-preset(string);
|
||||||
|
callback preset-name-edited(string);
|
||||||
|
/// Raised as the sheet opens over an open photograph, so Rust can say what
|
||||||
|
/// saving would capture. A property refreshed on every slider drag would
|
||||||
|
/// be recomputing a string nobody is looking at.
|
||||||
|
callback presets-opened();
|
||||||
|
|
||||||
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
|
||||||
//
|
//
|
||||||
// A page rather than an overlay, and the outermost of the view conditions
|
// A page rather than an overlay, and the outermost of the view conditions
|
||||||
@@ -1410,6 +1440,15 @@ in property <bool> panel-visible: true;
|
|||||||
paste-settings-to-selection => {
|
paste-settings-to-selection => {
|
||||||
root.paste-settings-to-selection();
|
root.paste-settings-to-selection();
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// Opened with the selection's size, which is both what the
|
||||||
|
// sheet says it would act on and what the apply handler
|
||||||
|
// reads back to decide it means the batch.
|
||||||
|
open-presets => {
|
||||||
|
root.preset-apply-count = root.library-selected-count;
|
||||||
|
root.preset-can-save = false;
|
||||||
|
root.presets-open = true;
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-EXP-7
|
// TRACES: FR-EXP-7
|
||||||
exporting: root.library-exporting;
|
exporting: root.library-exporting;
|
||||||
@@ -2203,6 +2242,15 @@ in property <bool> panel-visible: true;
|
|||||||
framing-withheld: root.settings-framing-withheld;
|
framing-withheld: root.settings-framing-withheld;
|
||||||
copy => { root.copy-settings(); }
|
copy => { root.copy-settings(); }
|
||||||
paste => { root.paste-settings(); }
|
paste => { root.paste-settings(); }
|
||||||
|
// Opened with no count, which is what tells
|
||||||
|
// the apply handler this means the open
|
||||||
|
// photograph rather than a selection.
|
||||||
|
open-presets => {
|
||||||
|
root.preset-apply-count = 0;
|
||||||
|
root.preset-can-save = root.adjust-enabled;
|
||||||
|
root.presets-opened();
|
||||||
|
root.presets-open = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -2402,6 +2450,26 @@ in property <bool> panel-visible: true;
|
|||||||
// Below the load bar, above everything else: a download started from
|
// Below the load bar, above everything else: a download started from
|
||||||
// here shows its progress in that bar, and the bar must not be the
|
// here shows its progress in that bar, and the bar must not be the
|
||||||
// thing the dialogue covers.
|
// thing the dialogue covers.
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// Over the shell rather than inside a view, because both views open
|
||||||
|
// it — and because the develop column is 320px wide, which is not
|
||||||
|
// enough to list presets and rename one in.
|
||||||
|
if root.presets-open: PresetSheet {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
names: root.preset-names;
|
||||||
|
can-save: root.preset-can-save;
|
||||||
|
apply-count: root.preset-apply-count;
|
||||||
|
capture-summary: root.preset-capture-summary;
|
||||||
|
name-error: root.preset-name-error;
|
||||||
|
save(name) => { root.save-preset(name); }
|
||||||
|
apply(name) => { root.apply-preset(name); }
|
||||||
|
rename(from, to) => { root.rename-preset(from, to); }
|
||||||
|
remove(name) => { root.delete-preset(name); }
|
||||||
|
name-edited(text) => { root.preset-name-edited(text); }
|
||||||
|
dismiss => { root.presets-open = false; }
|
||||||
|
}
|
||||||
|
|
||||||
OfflinePrompt {
|
OfflinePrompt {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -850,6 +850,9 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
callback export-selection();
|
callback export-selection();
|
||||||
callback cancel-export();
|
callback cancel-export();
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Open the named-preset sheet over the selection.
|
||||||
|
callback open-presets();
|
||||||
callback remove-from-collection();
|
callback remove-from-collection();
|
||||||
/// Open the sheet that files the selection in a collection.
|
/// Open the sheet that files the selection in a collection.
|
||||||
callback add-to-collection();
|
callback add-to-collection();
|
||||||
@@ -931,6 +934,20 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
clicked => { root.paste-settings-to-selection(); }
|
clicked => { root.paste-settings-to-selection(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The saved settings, beside the copied ones.
|
||||||
|
//
|
||||||
|
// Gated on the selection alone, unlike the paste beside it: that button
|
||||||
|
// needs a clipboard *this session*, where the preset list is whatever the
|
||||||
|
// photographer saved last month. Requiring an armed clipboard here would
|
||||||
|
// hide the saved presets behind an unrelated action — which is the shape
|
||||||
|
// of bug that makes a feature only its author knows about (FR-UI-4).
|
||||||
|
if root.selected-count > 0: Button {
|
||||||
|
text: "Presets";
|
||||||
|
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||||
|
clicked => { root.open-presets(); }
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||||||
// Export the selection, and stop the batch that is running.
|
// Export the selection, and stop the batch that is running.
|
||||||
//
|
//
|
||||||
@@ -1332,6 +1349,8 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
in property <bool> settings-armed: false;
|
in property <bool> settings-armed: false;
|
||||||
in property <string> settings-summary;
|
in property <string> settings-summary;
|
||||||
callback paste-settings-to-selection();
|
callback paste-settings-to-selection();
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
callback open-presets();
|
||||||
|
|
||||||
// TRACES: FR-EXP-7
|
// TRACES: FR-EXP-7
|
||||||
// Exporting the selection. The grid owns neither the settings that decide
|
// Exporting the selection. The grid owns neither the settings that decide
|
||||||
@@ -1698,6 +1717,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
export-selection => { root.export-selection(); }
|
export-selection => { root.export-selection(); }
|
||||||
cancel-export => { root.cancel-export(); }
|
cancel-export => { root.cancel-export(); }
|
||||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||||
|
open-presets => { root.open-presets(); }
|
||||||
remove-from-collection => { root.remove-from-collection(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
select-mode: root.select-mode;
|
select-mode: root.select-mode;
|
||||||
toggle-select-mode => { root.toggle-select-mode(); }
|
toggle-select-mode => { root.toggle-select-mode(); }
|
||||||
@@ -1783,6 +1803,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
export-selection => { root.export-selection(); }
|
export-selection => { root.export-selection(); }
|
||||||
cancel-export => { root.cancel-export(); }
|
cancel-export => { root.cancel-export(); }
|
||||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||||
|
open-presets => { root.open-presets(); }
|
||||||
remove-from-collection => { root.remove-from-collection(); }
|
remove-from-collection => { root.remove-from-collection(); }
|
||||||
select-mode: root.select-mode;
|
select-mode: root.select-mode;
|
||||||
toggle-select-mode => { root.toggle-select-mode(); }
|
toggle-select-mode => { root.toggle-select-mode(); }
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Button, Field, Caption } from "widgets.slint";
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The named preset sheet: save the edit in hand, and apply a saved one.
|
||||||
|
//
|
||||||
|
// # One sheet for both views
|
||||||
|
//
|
||||||
|
// A preset is saved in develop, where there is an edit to capture, and applied
|
||||||
|
// most often in the library, where there is a selection to apply it to. Those
|
||||||
|
// are two different moments and it is the same list, so this is one component
|
||||||
|
// mounted at the shell rather than a panel in each view — the same reasoning
|
||||||
|
// the clipboard's properties are declared on the window for.
|
||||||
|
//
|
||||||
|
// What differs between the two is not the sheet but its *answers*: `can-save`
|
||||||
|
// is false with nothing open, and `apply-count` says whether applying means
|
||||||
|
// this photograph or those forty. Both are facts the shell already holds.
|
||||||
|
//
|
||||||
|
// # The same card, scrim and dismissal as the filing and keywording sheets
|
||||||
|
//
|
||||||
|
// Deliberately. A user who has filed a selection knows how this works, and a
|
||||||
|
// second idiom for the same gesture would be a second thing to learn for no
|
||||||
|
// gain.
|
||||||
|
export component PresetSheet inherits Rectangle {
|
||||||
|
/// The saved names, in the order they are stored.
|
||||||
|
in property <[string]> names;
|
||||||
|
/// Whether there is an edit in hand to save. False in the library, where
|
||||||
|
/// nothing is open, and with an image that failed to decode.
|
||||||
|
in property <bool> can-save: false;
|
||||||
|
/// What a preset would be applied to: 0 means the open photograph, and
|
||||||
|
/// anything higher means that many selected ones.
|
||||||
|
in property <int> apply-count: 0;
|
||||||
|
/// What saving would capture — "3 adjustments" — from the same routine the
|
||||||
|
/// clipboard's summary comes from, so the two cannot disagree.
|
||||||
|
in property <string> capture-summary;
|
||||||
|
/// Set while a name is refused, and cleared by the next keystroke. Prose
|
||||||
|
/// rather than a code, because the shell knows why and this does not.
|
||||||
|
in property <string> name-error;
|
||||||
|
|
||||||
|
callback save(string);
|
||||||
|
callback apply(string);
|
||||||
|
callback rename(string, string);
|
||||||
|
callback remove(string);
|
||||||
|
callback dismiss();
|
||||||
|
/// Every keystroke in the name field, so the shell can clear a refusal the
|
||||||
|
/// user has started correcting.
|
||||||
|
callback name-edited(string);
|
||||||
|
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows the taps that miss the card, and closes. First, so the card's
|
||||||
|
// own controls sit above it.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which row is being renamed, by name. Empty means none.
|
||||||
|
//
|
||||||
|
// A name rather than an index: the list is rebuilt from Rust after every
|
||||||
|
// change, and an index would point at whatever moved into that slot.
|
||||||
|
property <string> renaming: "";
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
// Stops a press on the card reaching the scrim behind it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
sheet := VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Presets";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving, first: it is the half that has something to say about
|
||||||
|
// the photograph currently open, and it disappears entirely in the
|
||||||
|
// library rather than sitting there disabled — a permanently dead
|
||||||
|
// control teaches the reader that the sheet lies.
|
||||||
|
if root.can-save: VerticalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
name := Field {
|
||||||
|
placeholder: "Name this edit and press return";
|
||||||
|
accepted(text) => {
|
||||||
|
root.save(text);
|
||||||
|
// Cleared only once the shell has accepted it. A
|
||||||
|
// refused name the user has to retype is a refusal
|
||||||
|
// that costs more than the mistake did, so the field
|
||||||
|
// keeps the text and `name-error` says why.
|
||||||
|
if (root.name-error == "") {
|
||||||
|
self.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edited(text) => { root.name-edited(text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.name-error != "": Caption {
|
||||||
|
text: root.name-error;
|
||||||
|
warn: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bare summary, phrased exactly as the develop panel
|
||||||
|
// phrases the clipboard's — same routine, same words, so the
|
||||||
|
// two cannot appear to disagree about one edit.
|
||||||
|
if root.name-error == "": Caption {
|
||||||
|
text: root.capture-summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.can-save: Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
// A floor, so the list is not squeezed out of existence by the
|
||||||
|
// field and the button around it on a short window.
|
||||||
|
min-height: 120px;
|
||||||
|
viewport-height: root.names.length * (Theme.touch-target + 2px);
|
||||||
|
|
||||||
|
for entry[i] in root.names: Rectangle {
|
||||||
|
y: i * (Theme.touch-target + 2px);
|
||||||
|
width: parent.width;
|
||||||
|
height: Theme.touch-target;
|
||||||
|
|
||||||
|
if root.renaming != entry: HorizontalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
// The name is the apply button rather than a label
|
||||||
|
// beside one. Applying is what this list is for, and a
|
||||||
|
// row whose largest target does nothing is a row that
|
||||||
|
// gets pressed by accident and then distrusted.
|
||||||
|
Button {
|
||||||
|
text: entry;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
clicked => { root.apply(entry); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Rename";
|
||||||
|
clicked => { root.renaming = entry; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Delete";
|
||||||
|
clicked => { root.remove(entry); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming in place rather than in a second sheet: a
|
||||||
|
// dialogue over a dialogue is where a user loses track of
|
||||||
|
// which one Escape closes.
|
||||||
|
if root.renaming == entry: HorizontalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
rename-field := Field {
|
||||||
|
text: entry;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
accepted(text) => {
|
||||||
|
root.rename(entry, text);
|
||||||
|
root.renaming = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Cancel";
|
||||||
|
clicked => { root.renaming = ""; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.names.length == 0: Text {
|
||||||
|
text: root.can-save
|
||||||
|
? "No presets yet. Name the edit above to make the first."
|
||||||
|
: "No presets yet. Open a photograph and save one from the develop panel.";
|
||||||
|
color: Theme.ink-faint;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
wrap: word-wrap;
|
||||||
|
width: parent.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Says what applying would do *before* it is done, the same way
|
||||||
|
// the grid's "Paste to 40" does — a count in the label is worth
|
||||||
|
// more than a confirmation asking the same question afterwards.
|
||||||
|
if root.names.length > 0 && root.apply-count > 0: Caption {
|
||||||
|
text: root.apply-count == 1
|
||||||
|
? "Applies to 1 selected photograph"
|
||||||
|
: "Applies to " + root.apply-count + " selected photographs";
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.rule; }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Done";
|
||||||
|
clicked => { root.dismiss(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user