The presets menu and sheet listed every preset under flat section headings, seventy rows to scroll past. They now list folders, closed until opened, with how many presets each holds; opening one shows what is inside it, folders and presets indented beneath. A category is spelled in the name: "Portraits/Warm skin" is Warm skin in a Portraits folder under Yours. The file format does not change, so an older build lists the whole path as the name; renaming a preset is how it moves, and saving or renaming into a folder opens the way to it. A Lightroom import names what it reads after the folders below the one chosen, and a "/" in a displayed name becomes "∕" so it files nothing. The shipped film sections become Film › Colour, Cinema and Black and white. The tree is built and flattened in Rust (PresetTree), each row carrying its depth, and which folders are open is remembered for the session. A PopupWindow keeps the size it was shown at, so a folder opened in the menu pushed its contents under "Save or manage…"; the menu is shown again after each toggle to take its new height. That is a function on the rail because Slint 1.17 generates Rust that does not compile for a popup's close() reached from inside the popup. The sheet's list takes a preferred height of up to 400px, since a Flickable reports next to nothing and an opened folder showed three rows. The manual describes the folders and naming. Its pictures show the menu with Film › Colour open, and a black-and-white stock applied from Film › Black and white; the scenes aim popup rows from the rail's entry, and pick the menu's "Film" over the develop column's film chooser.
448 lines
16 KiB
Rust
448 lines
16 KiB
Rust
//! 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, as a category path: `/`
|
|
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
|
|
/// same spelling a photographer's own preset names use for theirs.
|
|
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"),
|
|
),
|
|
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
|
(
|
|
"colour_film",
|
|
"Film/Colour",
|
|
include_str!("../presets/colour_film.drpl"),
|
|
),
|
|
(
|
|
"cinema_film",
|
|
"Film/Cinema",
|
|
include_str!("../presets/cinema_film.drpl"),
|
|
),
|
|
(
|
|
"bw_film",
|
|
"Film/Black and white",
|
|
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);
|
|
}
|
|
}
|