//! 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")), ("vivid", "Vivid", include_str!("../presets/vivid.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
{ 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, } /// 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 { 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 { 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 = 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::>() }) .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 = 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); } }