Ship presets with the application instead of seeding them

The six starter presets were copied into the photographer's own library
on a first run and were theirs from then on. That cannot grow into a
real collection: a copy is frozen at the release that wrote it, so an
improved preset reaches nobody who had the old one, and re-seeding would
overwrite a preset someone had tuned.

`dr_pipeline::bundled` now holds the shipped presets as `.drpl` files
compiled into the binary, in sections — Essentials (the former six) and
three sections of film presets, one per measured stock in dr-film,
printed on the paper its profile names — and never writes them to the
user's file. Every shipped preset is a look (`Reach::Named`), so applying
one keeps the corrections a photograph already has.

A name links a photographer's copy to a shipped preset. Saving over a
shipped name makes their version the one that name applies; it is listed
in the shipped section, marked as changed, and deleting it reverts to the
shipped one. Renaming it makes it one of their own and the shipped preset
reappears. Keyed on the name because that is what the photographer sees
and chooses by.

Copies an older first run seeded are forgotten on load where they are
still exactly as seeded — otherwise all six would list as changed and
stay frozen at their old values. A tuned one is kept and now overrides.

The sheet lists "Yours" first, then each shipped section, with headings.
Shipped rows apply and nothing else; a changed row offers Revert where
the photographer's own offer Delete. A dr-ui test checks every shipped
film names a stock this build can bake, on that stock's own paper,
because dr-pipeline does not link the profile database.

The film presets name stocks by id; the measurements behind them are
spektrafilm's (CC BY-SA 4.0), attributed in each file as in dr-film.
This commit is contained in:
2026-09-26 13:44:32 -04:00
parent 90c0695c05
commit a7b090cf36
11 changed files with 828 additions and 338 deletions
+37
View File
@@ -0,0 +1,37 @@
drpl 1
# Black and white film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Ilford Delta 100]
film = ilford_delta_100
film_print = kodak_2302
[preset Ilford Delta 400]
film = ilford_delta_400
film_print = kodak_2302
[preset Ilford FP4 Plus]
film = ilford_fp4_plus
film_print = kodak_2302
[preset Ilford HP5 Plus]
film = ilford_hp5_plus
film_print = kodak_2302
[preset Ilford Pan F Plus]
film = ilford_pan_f_plus
film_print = kodak_2302
[preset Kodak Double-X 5222]
film = kodak_doublex
film_print = kodak_2302
[preset Kodak Tri-X Reversal 7266]
film = kodak_trix
+30
View File
@@ -0,0 +1,30 @@
drpl 1
# Cinema film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Kodak Verita 200D]
film = kodak_verita_200d
film_print = kodak_2383
[preset Kodak Vision3 200T]
film = kodak_vision3_200t
film_print = kodak_2383
[preset Kodak Vision3 250D]
film = kodak_vision3_250d
film_print = kodak_2383
[preset Kodak Vision3 500T]
film = kodak_vision3_500t
film_print = kodak_2383
[preset Kodak Vision3 50D]
film = kodak_vision3_50d
film_print = kodak_2383
+66
View File
@@ -0,0 +1,66 @@
drpl 1
# Colour film: each preset is one measured stock, developed and — for a
# negative — printed on the paper its profile names, with every other
# control left where it was. The stock is the look; a grade on top is the
# photographer's to add.
#
# The measurements are spektrafilm's (Andrea Volpato, CC BY-SA 4.0), as
# converted in core/dr-film/profiles. A preset names a stock by id, so
# these lines are that attribution's reach: nothing of the data is here.
[preset Fujifilm C200]
film = fujifilm_c200
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Pro 400H]
film = fujifilm_pro_400h
film_print = fujifilm_crystal_archive_typeii
[preset Fujifilm Provia 100F]
film = fujifilm_provia_100f
[preset Fujifilm Velvia 100]
film = fujifilm_velvia_100
[preset Fujifilm X-Tra 400]
film = fujifilm_xtra_400
film_print = fujifilm_crystal_archive_typeii
[preset Kodak Ektachrome 100]
film = kodak_ektachrome_100
[preset Kodak Ektar 100]
film = kodak_ektar_100
film_print = kodak_portra_endura
[preset Kodak Gold 200]
film = kodak_gold_200
film_print = kodak_portra_endura
[preset Kodak Kodachrome 64]
film = kodak_kodachrome_64
[preset Kodak Portra 160]
film = kodak_portra_160
film_print = kodak_portra_endura
[preset Kodak Portra 400]
film = kodak_portra_400
film_print = kodak_portra_endura
[preset Kodak Portra 800]
film = kodak_portra_800
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed one stop]
film = kodak_portra_800_push1
film_print = kodak_portra_endura
[preset Kodak Portra 800 pushed two stops]
film = kodak_portra_800_push2
film_print = kodak_portra_endura
[preset Kodak Ultramax 400]
film = kodak_ultramax_400
film_print = kodak_portra_endura
+41
View File
@@ -0,0 +1,41 @@
drpl 1
# Essentials: small, general corrections written against this pipeline.
# These were the first-run starter set; they ship here now so that a
# new release can improve them without rewriting anybody's own presets.
# Deliberately mild — see bundled.rs.
[preset Crisp detail]
capture_sharpen.amount = 35
clarity.amount = 10
texture.amount = 20
[preset Lift the shadows]
blacks_whites.blacks = 12
contrast.contrast = -5
highlights_shadows.shadows = 40
[preset Muted]
contrast.contrast = -10
highlights_shadows.shadows = 12
saturation.saturation = -30
vibrance.vibrance = 10
[preset Punch]
blacks_whites.blacks = -8
clarity.amount = 12
contrast.contrast = 18
vibrance.vibrance = 18
[preset Recover the sky]
blacks_whites.whites = -10
highlights_shadows.highlights = -55
highlights_shadows.shadows = 35
[preset Soft portrait]
clarity.amount = -10
contrast.contrast = -8
highlights_shadows.highlights = -20
highlights_shadows.shadows = 15
saturation.saturation = -5
vibrance.vibrance = 10
+444
View File
@@ -0,0 +1,444 @@
//! TRACES: FR-DEV-6
//! The presets that ship with the application.
//!
//! # Shipped, not seeded
//!
//! The first six used to be *copied* into the photographer's own library on
//! the first run and were theirs from then on. That was the right answer for
//! six, and it cannot grow: a copy is frozen at the version that made it, so a
//! better "Portra" in the next release would reach nobody who already had the
//! old one, and re-seeding would overwrite a preset someone had tuned. So the
//! shipped set is now read from the binary every time, never written to the
//! user's file, and changes when the application does.
//!
//! # Your copy wins, as long as it keeps the name
//!
//! Saving over a shipped preset's name makes the photographer's version the
//! one that name means, here and in every apply. It is still *that* preset —
//! listed where the shipped one was, marked as changed — and deleting it
//! reveals the shipped one again, which is what "revert" means to the person
//! pressing it. Renaming it cuts the link: it becomes one of their own, and
//! the shipped preset reappears beside it. The lookup is by name because the
//! name is what the photographer sees and chooses by; an id they never see
//! would link two presets they believe are different.
//!
//! # Looks, not whole edits
//!
//! Every shipped preset reaches only the operations it names
//! ([`Reach::Named`]): a look applied to a corrected photograph must keep the
//! correction. A photographer's own saved edits keep [`Reach::Whole`], which
//! is what saving an edit has always meant.
//!
//! # Why the data is text files
//!
//! The same format the user's library is written in, so a shipped preset can
//! be read, diffed and copied into one's own library by hand, and each file
//! can say in a comment where its looks came from — the attribution a licence
//! may require travels with the data it covers.
//!
//! # Why this lives in the core
//!
//! It names operations — "Punch" is a statement about contrast and clarity —
//! and nothing in `ui/` may (`ui_names_no_operation.rs`, ARCH §4.3a). The
//! frontend asks for the listing and applies what it is handed.
use crate::preset::{Preset, PresetLibrary, Reach};
/// One group of shipped presets, as the sheet lists it.
pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown.
pub id: &'static str,
/// What the section is called on screen.
pub title: &'static str,
/// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary,
}
/// The files, in the order the sheet lists them.
const SECTIONS: &[(&str, &str, &str)] = &[
(
"essentials",
"Essentials",
include_str!("../presets/essentials.drpl"),
),
(
"colour_film",
"Colour film",
include_str!("../presets/colour_film.drpl"),
),
(
"cinema_film",
"Cinema film",
include_str!("../presets/cinema_film.drpl"),
),
(
"bw_film",
"Black and white film",
include_str!("../presets/bw_film.drpl"),
),
];
/// Every shipped section, parsed.
///
/// Parsed on each call rather than held: it is a few kilobytes read when the
/// preset sheet is drawn, and a static would be one more thing to keep
/// consistent with the files in a test. A file that fails to parse costs its
/// section, with a warning, rather than the sheet — and the tests below make
/// sure none does.
pub fn sections() -> Vec<Section> {
SECTIONS
.iter()
.filter_map(|(id, title, text)| match PresetLibrary::parse(text) {
Ok(library) => Some(Section {
id,
title,
presets: as_looks(library),
}),
Err(e) => {
log::warn!("shipped preset section {id} is unreadable ({e}); skipping");
None
}
})
.collect()
}
/// Mark every preset in `library` as a look.
///
/// Here rather than as a `reach = named` line in every block of every file:
/// the rule is about where a preset came from, and a file that forgot the
/// line would ship a preset that wiped a photographer's corrections.
fn as_looks(library: PresetLibrary) -> PresetLibrary {
let mut looks = PresetLibrary::default();
for (name, preset) in library.iter() {
let _ = looks.insert(name, preset.clone().with_reach(Reach::Named));
}
looks
}
/// Where a listed preset comes from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
/// The photographer's own, with no shipped preset of that name.
Yours,
/// Shipped, and not overridden.
Shipped,
/// Shipped, and overridden by the photographer's copy under the same
/// name. The copy is what applies; deleting it reverts to the shipped one.
Changed,
}
/// One row of the preset sheet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub name: String,
pub origin: Origin,
}
/// One group of rows: the photographer's own, then each shipped section.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedSection {
/// `None` for the photographer's own presets.
pub id: Option<&'static str>,
pub title: &'static str,
pub rows: Vec<Listed>,
}
/// Everything the sheet lists, in order, with the photographer's copies
/// standing in for the shipped presets they override.
///
/// Their own presets come first, because a photographer reaches for their own
/// work more than for anybody's defaults, and a copy of a shipped preset is
/// listed in the shipped section rather than among their own — it is still
/// that preset, changed, and belongs where they would look for it.
pub fn listing(yours: &PresetLibrary) -> Vec<ListedSection> {
let shipped = sections();
let is_shipped = |name: &str| shipped.iter().any(|section| section.presets.contains(name));
let mut out = vec![ListedSection {
id: None,
title: "Yours",
rows: yours
.names()
.filter(|name| !is_shipped(name))
.map(|name| Listed {
name: name.to_string(),
origin: Origin::Yours,
})
.collect(),
}];
out.extend(shipped.iter().map(|section| {
ListedSection {
id: Some(section.id),
title: section.title,
rows: section
.presets
.names()
.map(|name| Listed {
name: name.to_string(),
origin: if yours.contains(name) {
Origin::Changed
} else {
Origin::Shipped
},
})
.collect(),
}
}));
out
}
/// The preset a name means: the photographer's if they have one, the shipped
/// one otherwise.
pub fn lookup(yours: &PresetLibrary, name: &str) -> Option<Preset> {
yours.get(name).cloned().or_else(|| {
sections()
.into_iter()
.find_map(|section| section.presets.get(name).cloned())
})
}
/// Whether `name` is a shipped preset's.
pub fn is_shipped(name: &str) -> bool {
sections()
.iter()
.any(|section| section.presets.contains(name))
}
/// Remove the copies a first run used to seed, where they are still exactly
/// as seeded. Returns how many went.
///
/// Those copies would otherwise all list as changed — overriding a shipped
/// preset with an identical one — and would freeze the six at their old
/// values forever. One that differs in any way was tuned by somebody and is
/// kept: it is theirs, and it now overrides the shipped one, which is the
/// rule above doing what it is for.
///
/// Compared on the parameters and the film and not on [`Reach`]: the seeded
/// copies were whole edits, the shipped ones are looks, and that difference
/// is the thing this migration exists to deliver.
pub fn forget_unchanged_copies(yours: &mut PresetLibrary) -> usize {
let shipped = sections();
let stale: Vec<String> = yours
.iter()
.filter(|(name, preset)| {
shipped.iter().any(|section| {
section
.presets
.get(name)
.is_some_and(|s| s.params() == preset.params() && s.film() == preset.film())
})
})
.map(|(name, _)| name.to_string())
.collect();
for name in &stale {
yours.remove(name);
}
stale.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
fn all() -> Vec<(&'static str, String, Preset)> {
sections()
.into_iter()
.flat_map(|s| {
let id = s.id;
s.presets
.iter()
.map(|(n, p)| (id, n.to_string(), p.clone()))
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn every_file_parses_and_every_line_is_understood() {
// A misspelt key would otherwise be kept as a line this build does
// not understand — preserved faithfully, and doing nothing.
assert_eq!(
sections().len(),
SECTIONS.len(),
"a section failed to parse"
);
for (id, _, text) in SECTIONS {
let library = PresetLibrary::parse(text).unwrap();
assert_eq!(library.unread_lines(), 0, "{id} has lines nobody reads");
assert!(!library.is_empty(), "{id} is empty");
}
}
#[test]
fn every_shipped_name_is_unique_across_sections() {
// A name is what the lookup and the override key on. Two shipped
// presets sharing one would make one of them unreachable.
let mut names: Vec<String> = all().into_iter().map(|(_, n, _)| n).collect();
let before = names.len();
names.sort();
names.dedup();
assert_eq!(names.len(), before);
}
#[test]
fn every_shipped_preset_is_a_look() {
for (id, name, preset) in all() {
assert_eq!(preset.reach(), Reach::Named, "{id}/{name}");
}
}
#[test]
fn every_shipped_preset_names_parameters_this_build_actually_has() {
// A renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for (id, name, preset) in all() {
for (op, param) in preset.params().keys() {
let capability = capabilities
.iter()
.find(|c| c.id.0 == op)
.unwrap_or_else(|| panic!("{id}/{name}: no operation {op:?}"));
assert!(
capability.params.iter().any(|p| p.id.0 == param),
"{id}/{name}: operation {op:?} has no parameter {param:?}"
);
}
}
}
#[test]
fn every_shipped_preset_changes_something() {
// A preset that applies to nothing teaches the photographer that the
// list does not work.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
let rebake = preset.apply(&mut graph, Scope::adjustments());
assert!(
rebake.wanted().is_some() || Preset::capture(&graph) != Preset::default(),
"{id}/{name} left the graph at its defaults"
);
}
}
#[test]
fn no_shipped_preset_carries_a_crop() {
for (id, name, preset) in all() {
assert!(!preset.touches_framing(), "{id}/{name} carries framing");
}
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal would be
// silently trimmed and the preset would not be the one written.
for (id, name, preset) in all() {
let mut graph = EditGraph::default_chain();
preset
.clone()
.with_film(None)
.apply(&mut graph, Scope::everything())
.expect_no_film();
for ((op, param), value) in preset.params() {
let (op_id, param_id) = crate::preset::resolve(&graph, op, param).unwrap();
assert_eq!(
graph.param(op_id, param_id),
Some(*value),
"{id}/{name}: {op}.{param} = {value} was clamped"
);
}
}
}
// --- the listing and the override ------------------------------------
fn yours_with(names: &[(&str, Preset)]) -> PresetLibrary {
let mut lib = PresetLibrary::default();
for (n, p) in names {
lib.insert(n, p.clone()).unwrap();
}
lib
}
fn first_shipped() -> (String, Preset) {
let (_, name, preset) = all().into_iter().next().unwrap();
(name, preset)
}
#[test]
fn your_copy_under_a_shipped_name_is_what_that_name_applies() {
let (name, shipped) = first_shipped();
let mine = Preset::default();
let yours = yours_with(&[(&name, mine.clone())]);
assert_eq!(lookup(&yours, &name), Some(mine));
assert_eq!(lookup(&PresetLibrary::default(), &name), Some(shipped));
}
#[test]
fn your_copy_is_listed_in_the_shipped_section_as_changed() {
let (name, _) = first_shipped();
let yours = yours_with(&[(&name, Preset::default()), ("Mine", Preset::default())]);
let listing = listing(&yours);
assert_eq!(listing[0].id, None);
assert_eq!(
listing[0].rows,
vec![Listed {
name: "Mine".into(),
origin: Origin::Yours
}],
"an override must not be listed twice"
);
let row = listing[1..]
.iter()
.flat_map(|s| &s.rows)
.find(|r| r.name == name)
.unwrap();
assert_eq!(row.origin, Origin::Changed);
}
#[test]
fn deleting_your_copy_reverts_to_the_shipped_one() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.remove(&name);
assert_eq!(lookup(&yours, &name), Some(shipped));
}
#[test]
fn renaming_your_copy_cuts_the_link() {
let (name, shipped) = first_shipped();
let mut yours = yours_with(&[(&name, Preset::default())]);
yours.rename(&name, "My version").unwrap();
assert_eq!(lookup(&yours, &name), Some(shipped));
assert_eq!(lookup(&yours, "My version"), Some(Preset::default()));
assert_eq!(listing(&yours)[0].rows[0].name, "My version");
}
#[test]
fn the_old_seeded_copies_are_forgotten_and_tuned_ones_kept() {
// The six as a first run wrote them: the same parameters, as whole
// edits, because that is what a seeded copy was.
let essentials = sections().into_iter().next().unwrap().presets;
let mut yours = PresetLibrary::default();
for (name, preset) in essentials.iter() {
yours
.insert(name, preset.clone().with_reach(Reach::Whole))
.unwrap();
}
let tuned = essentials.names().next().unwrap().to_string();
yours.insert(&tuned, Preset::default()).unwrap();
yours.insert("Mine", Preset::default()).unwrap();
let forgotten = forget_unchanged_copies(&mut yours);
assert_eq!(forgotten, essentials.len() - 1);
assert!(yours.contains(&tuned), "a tuned copy was thrown away");
assert!(yours.contains("Mine"));
assert_eq!(yours.len(), 2);
}
}
+1 -1
View File
@@ -32,6 +32,7 @@
//! single multiply and white balance a per-channel scale; on gamma-encoded
//! data neither would be physically meaningful (ARCH §5.2).
pub mod bundled;
pub mod coverage;
pub mod declared;
pub mod descriptor;
@@ -48,7 +49,6 @@ pub mod orphan;
pub mod preset;
pub mod sidecar;
pub mod spot;
pub mod starter;
pub mod state;
pub use coverage::Coverage;
-222
View File
@@ -1,222 +0,0 @@
//! TRACES: FR-DEV-6
//! The presets a first run starts with.
//!
//! # Why any at all
//!
//! A preset sheet that opens on "No presets yet" teaches the photographer that
//! the feature is homework. These exist so the first thing the sheet does is
//! demonstrate what a preset *is* — and so that applying one to a selection,
//! which is the action worth discovering, is available before anybody has
//! saved anything.
//!
//! # Why these are ours and not Adobe's
//!
//! Lightroom ships a large bundled set, and importing one of *those* files is
//! what [`crate::preset_import`] is for — a photographer's own library,
//! carried across. Redistributing Adobe's inside this application would be
//! shipping their creative work under a licence that does not permit it, which
//! is a reason on its own; and their numbers are calibrated against their tone
//! curve rather than ours, so the look would not survive the trip even if the
//! licence allowed it.
//!
//! So these are written against this pipeline, in its units, and they are
//! deliberately mild. A starter preset is a starting point — a photographer
//! who wanted the full effect can push the sliders, where one who is handed a
//! caricature learns to distrust the list.
//!
//! # Why they live in the core rather than in the interface
//!
//! Because they name operations, and nothing in `ui/` may
//! (`ui_names_no_operation.rs`, ARCH §4.3a). That test is right to object: a
//! preset called "Punch" *is* a statement about contrast, clarity and
//! vibrance, which makes it a statement in the pipeline's vocabulary rather
//! than a fact about any interface. The frontend asks for the set and stores
//! it; it never learns what is in it.
//!
//! # Why they are seeded rather than merged
//!
//! Written once, on the first run that finds no library at all, and never
//! again. Re-adding them on every start would resurrect one the photographer
//! deleted on purpose, and updating them in place would silently rewrite an
//! edit they had adjusted and kept under the same name. After the first run
//! these are ordinary presets: renameable, editable, deletable, and gone for
//! good when deleted.
use std::collections::BTreeMap;
use crate::{Preset, PresetLibrary};
/// One starter preset: a name and the parameters that differ from default.
struct Starter {
name: &'static str,
params: &'static [(&'static str, &'static str, f32)],
}
/// The set. Small on purpose — six a photographer might actually reach for
/// beats forty they have to scroll past.
const STARTERS: &[Starter] = &[
Starter {
name: "Punch",
params: &[
("contrast", "contrast", 18.0),
("clarity", "amount", 12.0),
("vibrance", "vibrance", 18.0),
("blacks_whites", "blacks", -8.0),
],
},
Starter {
name: "Soft portrait",
params: &[
("contrast", "contrast", -8.0),
("highlights_shadows", "highlights", -20.0),
("highlights_shadows", "shadows", 15.0),
("clarity", "amount", -10.0),
("vibrance", "vibrance", 10.0),
("saturation", "saturation", -5.0),
],
},
Starter {
name: "Recover the sky",
params: &[
// The most common single fix in landscape work: a bright sky and a
// dark foreground, both pulled back toward the middle.
("highlights_shadows", "highlights", -55.0),
("highlights_shadows", "shadows", 35.0),
("blacks_whites", "whites", -10.0),
],
},
Starter {
name: "Lift the shadows",
params: &[
("highlights_shadows", "shadows", 40.0),
("blacks_whites", "blacks", 12.0),
("contrast", "contrast", -5.0),
],
},
Starter {
name: "Crisp detail",
params: &[
("texture", "amount", 20.0),
("clarity", "amount", 10.0),
("capture_sharpen", "amount", 35.0),
],
},
Starter {
name: "Muted",
params: &[
("saturation", "saturation", -30.0),
("vibrance", "vibrance", 10.0),
("contrast", "contrast", -10.0),
("highlights_shadows", "shadows", 12.0),
],
},
];
/// The starter library, for a device that has never had one.
pub fn library() -> PresetLibrary {
let mut library = PresetLibrary::default();
for starter in STARTERS {
let params: BTreeMap<(String, String), f32> = starter
.params
.iter()
.map(|(op, param, value)| ((op.to_string(), param.to_string()), *value))
.collect();
// The name is a literal in this file, so a refusal would be a bug here
// rather than bad input — but it still must not take the whole set
// down, since the alternative to five presets is not six, it is none.
if let Err(e) = library.insert(starter.name, Preset::from_params(params)) {
log::warn!(
"starter preset {:?} is unusable ({e:?}); skipping",
starter.name
);
}
}
library
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditGraph, Scope};
#[test]
fn every_starter_names_parameters_this_build_actually_has() {
// The same guard the importer's table has, for the same reason: a
// renamed parameter must break the build rather than ship a preset
// that quietly does nothing.
let graph = EditGraph::default_chain();
let capabilities = graph.capabilities();
for starter in STARTERS {
for (op, param, _) in starter.params {
let capability = capabilities
.iter()
.find(|c| c.id.0 == *op)
.unwrap_or_else(|| panic!("{:?}: no operation {op:?}", starter.name));
assert!(
capability.params.iter().any(|p| p.id.0 == *param),
"{:?}: operation {op:?} has no parameter {param:?}",
starter.name
);
}
}
}
#[test]
fn every_starter_actually_changes_something() {
// A preset that applies to nothing is worse than one fewer preset: it
// teaches the photographer that the list does not work.
for (name, preset) in library().iter() {
assert!(!preset.is_empty(), "{name} carries nothing");
let mut graph = EditGraph::default_chain();
preset
.apply(&mut graph, Scope::adjustments())
.expect_no_film();
assert_ne!(
Preset::capture(&graph),
Preset::default(),
"{name} left the graph at its defaults"
);
}
}
#[test]
fn no_starter_carries_a_crop() {
// These are looks, not compositions. One that re-framed every image it
// was applied to would be the exact accident `Scope`'s default exists
// to prevent.
for (name, preset) in library().iter() {
assert!(!preset.touches_framing(), "{name} carries framing");
}
}
#[test]
fn the_names_are_distinct() {
assert_eq!(library().len(), STARTERS.len());
}
#[test]
fn the_values_stay_inside_what_the_controls_accept() {
// Clamping happens on apply, so an out-of-range literal here would be
// silently trimmed and the preset would not be the one written.
let graph = EditGraph::default_chain();
for starter in STARTERS {
for (op, param, value) in starter.params {
let mut applied = EditGraph::default_chain();
let capability = graph
.capabilities()
.into_iter()
.find(|c| c.id.0 == *op)
.unwrap();
let descriptor = capability.params.iter().find(|p| p.id.0 == *param).unwrap();
applied.set_param(capability.id, descriptor.id, *value);
assert_eq!(
applied.param(capability.id, descriptor.id),
Some(*value),
"{}: {op}.{param} = {value} was clamped",
starter.name
);
}
}
}
}
File diff suppressed because one or more lines are too long
+142 -80
View File
@@ -739,26 +739,19 @@ impl NamedPresets {
fn at(store: PresetStore) -> Rc<Self> {
// TRACES: FR-DEV-6
// Seed the starter set on a device that has never had a library, and
// only then. See `preset_starter` for why this is not a merge:
// re-adding on every start would resurrect one deleted on purpose.
//
// "Never had one" is the *file* not existing rather than the library
// being empty — a photographer who deleted all six must not be handed
// them again on the next launch.
let seeding = !store.path().exists();
let library = if seeding {
let starters = dr_pipeline::starter::library();
if let Err(e) = store.save(&starters) {
// Not fatal. The presets are in memory and usable this
// session; what is lost is their persistence, and refusing to
// start over it would be absurd.
log::warn!("could not write the starter presets: {e}");
}
starters
} else {
store.load()
};
// 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),
@@ -808,14 +801,45 @@ impl NamedPresets {
message
}
/// The stored names, in the order they are written.
/// The photographer's own stored names, in the order they are written.
#[cfg(test)]
pub fn names(&self) -> Vec<String> {
self.library.borrow().names().map(String::from).collect()
}
/// The preset stored under `name`.
/// The preset `name` applies: the photographer's if they have one under
/// that name, the shipped one otherwise.
pub fn get(&self, name: &str) -> Option<Preset> {
self.library.borrow().get(name).cloned()
dr_pipeline::bundled::lookup(&self.library.borrow(), name)
}
/// Every row the sheet lists, headings included.
///
/// The photographer's own section is left out while it is empty: a
/// heading over nothing reads as a list that failed to load.
pub fn rows(&self) -> Vec<crate::PresetRow> {
use dr_pipeline::bundled::Origin;
let mut rows = Vec::new();
for section in dr_pipeline::bundled::listing(&self.library.borrow()) {
if section.rows.is_empty() {
continue;
}
rows.push(crate::PresetRow {
heading: section.title.into(),
name: Default::default(),
origin: crate::PresetOrigin::Shipped,
});
rows.extend(section.rows.into_iter().map(|row| crate::PresetRow {
heading: Default::default(),
name: row.name.into(),
origin: match row.origin {
Origin::Yours => crate::PresetOrigin::Yours,
Origin::Shipped => crate::PresetOrigin::Shipped,
Origin::Changed => crate::PresetOrigin::Changed,
},
}));
}
rows
}
/// Whether a preset is stored under `name`.
@@ -898,10 +922,9 @@ impl SaveError {
}
}
/// Push the stored names onto the window.
/// Push the listing 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)));
window.set_preset_rows(slint::ModelRc::new(slint::VecModel::from(named.rows())));
}
/// Wire saving, applying, renaming and deleting named presets.
@@ -1452,69 +1475,112 @@ mod tests {
// Named presets
// -----------------------------------------------------------------------
/// A library that has *already been started*, so the starter presets are
/// not in the way.
///
/// Writing an empty library first is what makes the file exist, which is
/// the condition seeding tests — see `NamedPresets::at`. The seeding
/// itself has its own tests below rather than being switched off with a
/// flag that only tests would set.
/// A library in a fresh directory of its own.
fn named(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
let path = dir.join("presets.drpl");
PresetStore::open_at(path.clone())
.save(&PresetLibrary::default())
.unwrap();
(NamedPresets::open_at(path), dir)
}
/// A device that has never had a preset library.
fn fresh(name: &str) -> (Rc<NamedPresets>, PathBuf) {
let dir = tempdir(name);
(NamedPresets::open_at(dir.join("presets.drpl")), dir)
}
#[test]
fn a_first_run_is_handed_the_starter_presets() {
// A sheet that opens on "No presets yet" teaches the photographer that
// the feature is homework.
let (presets, _dir) = fresh("first-run");
assert_eq!(presets.names().len(), dr_pipeline::starter::library().len());
assert!(!presets.names().is_empty());
/// The rows under the heading `title`, as (name, origin).
fn section(presets: &NamedPresets, title: &str) -> Vec<(String, crate::PresetOrigin)> {
presets
.rows()
.into_iter()
.skip_while(|r| r.heading != title)
.skip(1)
.take_while(|r| r.heading.is_empty())
.map(|r| (r.name.to_string(), r.origin))
.collect()
}
#[test]
fn the_starters_are_written_out_so_they_survive_a_restart() {
let (_presets, dir) = fresh("starters-persist");
let path = dir.join("presets.drpl");
assert!(path.exists(), "the starter library was never written");
assert!(!PresetStore::open_at(path).load().is_empty());
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 a_starter_the_photographer_deleted_stays_deleted() {
// The reason seeding keys on the file existing rather than on the
// library being empty: deleting all six must not hand them back on the
// next launch.
let dir = tempdir("deleted-starters");
let path = dir.join("presets.drpl");
{
let presets = NamedPresets::open_at(path.clone());
for name in presets.names() {
let mut library = presets.library.borrow_mut();
library.remove(&name);
}
presets.persist(|_| {}).unwrap();
}
let again = NamedPresets::open_at(path);
assert!(
again.names().is_empty(),
"the starters came back: {:?}",
again.names()
fn an_empty_section_of_your_own_is_not_listed() {
let (presets, _dir) = named("no-yours");
assert!(presets.rows().iter().all(|r| r.heading != "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 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");
@@ -1613,11 +1679,7 @@ mod tests {
let presets = NamedPresets::open_at(blocked.join("presets.drpl"));
assert!(presets.insert("Warm", Preset::default()).is_err());
// Not "the library is empty": the starter set is held in memory even
// on a device whose config directory cannot be written, which is the
// right behaviour — the presets are usable this session and only their
// persistence is lost. What must not survive is the preset whose write
// failed.
// What must not survive is the preset whose write failed.
assert!(
!presets.contains("Warm"),
"the failed save left a preset behind: {:?}",
+3 -3
View File
@@ -14,7 +14,7 @@ import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, Collection
MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { CopySheet, PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { CopySheet, PresetOrigin, PresetRow, PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { ExportOptions, ExportSheet } from "export.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint";
@@ -499,7 +499,7 @@ export component AppWindow inherits Window {
// 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;
in property <[PresetRow]> preset-rows;
/// 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;
@@ -3412,7 +3412,7 @@ in property <bool> panel-visible: true;
if root.presets-open: PresetSheet {
width: 100%;
height: 100%;
names: root.preset-names;
rows: root.preset-rows;
can-save: root.preset-can-save;
apply-count: root.preset-apply-count;
capture-summary: root.preset-capture-summary;
+58 -26
View File
@@ -16,6 +16,31 @@ export struct ScopeKind {
on: bool,
}
// TRACES: FR-DEV-6
// Where a listed preset comes from, which decides what can be done to it.
export enum PresetOrigin {
// The photographer's own.
yours,
// Shipped with the application. Applied, never renamed or deleted.
shipped,
// The photographer's copy under a shipped preset's name, standing in for
// it. Deleting it reverts to the shipped one; renaming it makes it theirs.
changed,
}
// TRACES: FR-DEV-6
// One row of the preset sheet: a section heading, or a preset.
//
// One flat list with the headings in it rather than a list of sections, for
// the reason the rows are placed by index below: a nested `for` inside a
// `Flickable` has no layout to size it, and the arithmetic stays one line.
export struct PresetRow {
// Non-empty on a heading row, which names no preset.
heading: string,
name: string,
origin: PresetOrigin,
}
// TRACES: FR-DEV-6 | FR-UI-2
// Which kinds of edit travel: a multi-select block of chips that wraps.
//
@@ -78,8 +103,9 @@ export component ScopeChips inherits Rectangle {
// 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;
/// The photographer's presets and the shipped ones, sectioned, in the
/// order they are listed.
in property <[PresetRow]> rows;
/// 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;
@@ -198,14 +224,21 @@ export component PresetSheet inherits Rectangle {
// 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);
viewport-height: root.rows.length * (Theme.touch-target + 2px);
for entry[i] in root.names: Rectangle {
for entry[i] in root.rows: Rectangle {
y: i * (Theme.touch-target + 2px);
width: parent.width;
height: Theme.touch-target;
if root.renaming != entry: HorizontalLayout {
if entry.heading != "": PanelHeading {
text: entry.heading;
width: parent.width;
height: parent.height;
vertical-alignment: bottom;
}
if entry.heading == "" && root.renaming != entry.name: HorizontalLayout {
spacing: Theme.gap-sm;
// The name is the apply button rather than a label
@@ -213,33 +246,42 @@ export component PresetSheet inherits Rectangle {
// row whose largest target does nothing is a row that
// gets pressed by accident and then distrusted.
Button {
text: entry;
text: entry.origin == PresetOrigin.changed
? entry.name + " · changed"
: entry.name;
horizontal-stretch: 1;
clicked => { root.apply(entry); }
clicked => { root.apply(entry.name); }
}
Button {
// A shipped preset is applied and nothing else: it is
// not the photographer's to rename or delete, and a
// button that did either would be one that lied.
// Saving over its name is how it is changed.
if entry.origin != PresetOrigin.shipped: Button {
text: "Rename";
clicked => { root.renaming = entry; }
clicked => { root.renaming = entry.name; }
}
Button {
text: "Delete";
clicked => { root.remove(entry); }
// "Revert" on a changed one, because that is what
// deleting the copy does: the shipped preset is
// still there underneath it.
if entry.origin != PresetOrigin.shipped: Button {
text: entry.origin == PresetOrigin.changed ? "Revert" : "Delete";
clicked => { root.remove(entry.name); }
}
}
// 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 {
if entry.heading == "" && root.renaming == entry.name: HorizontalLayout {
spacing: Theme.gap-sm;
rename-field := Field {
text: entry;
text: entry.name;
horizontal-stretch: 1;
accepted(text) => {
root.rename(entry, text);
root.rename(entry.name, text);
root.renaming = "";
}
}
@@ -252,20 +294,10 @@ export component PresetSheet inherits Rectangle {
}
}
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 {
if root.apply-count > 0: Caption {
text: root.apply-count == 1
? "Applies to 1 selected photograph"
: "Applies to " + root.apply-count + " selected photographs";