//! Localisation keys to display strings. //! //! The core deals only in [`LocalizedKey`]s — resolving one needs a //! localiser, and `core/` must not depend on a localisation library //! (ARCH §3.3, NFR-A11Y-1). This is the UI's catalogue. //! //! **Unknown keys resolve to something readable rather than empty.** A new //! operation added to the pipeline appears in the interface immediately, with //! a derived label, before anyone writes a translation for it. That is the //! behaviour FR-DEV-3c promises: adding an operation needs no UI change. /// TRACES: FR-DEV-5 /// The history steps this interface records that no descriptor can name. /// /// Constants rather than string literals at the call sites, and [`ALL`] rather /// than a list written out again in a test. A key spelled one way where the /// step is recorded and another way where it is catalogued resolves through /// `derive` to something *plausible* — "Mask Toggled" — so the mistake does /// not look like one. Naming each key once removes the opportunity. /// /// A step that moved a parameter or an operation is deliberately not here: it /// is named out of the descriptor, through the same `op.` and `param.` entries /// the develop panel resolves. That is what lets an operation added as a YAML /// declaration appear in the history correctly named with nothing written for /// it here (FR-DEV-3c). pub mod step { use dr_pipeline::LocalizedKey; pub const PASTE: LocalizedKey = LocalizedKey("history.paste"); pub const FILM: LocalizedKey = LocalizedKey("history.film"); /// TRACES: FR-DEV-5 /// The photograph put back to a named snapshot, as one step. pub const SNAPSHOT_RESTORED: LocalizedKey = LocalizedKey("history.snapshot_restored"); /// TRACES: FR-DEV-3 /// A neutral was picked off the photograph, setting the white balance. /// /// Named for what the photographer did rather than for the parameters it /// moved: two of them changed, and "Temperature" beside "Tint" in the list /// would describe the mechanism rather than the decision. pub const SAMPLED_NEUTRAL: LocalizedKey = LocalizedKey("history.sampled_neutral"); pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all"); pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op"); pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param"); pub const RESET_FRAMING: LocalizedKey = LocalizedKey("history.reset_framing"); pub const ROTATE: LocalizedKey = LocalizedKey("history.rotate"); pub const FLIP_H: LocalizedKey = LocalizedKey("history.flip_h"); pub const FLIP_V: LocalizedKey = LocalizedKey("history.flip_v"); pub const MASK_ADDED: LocalizedKey = LocalizedKey("history.mask_added"); pub const MASK_REMOVED: LocalizedKey = LocalizedKey("history.mask_removed"); pub const MASK_TOGGLED: LocalizedKey = LocalizedKey("history.mask_toggled"); pub const MASK_INVERTED: LocalizedKey = LocalizedKey("history.mask_inverted"); pub const MASK_MOVED: LocalizedKey = LocalizedKey("history.mask_moved"); pub const MASK_FEATHER: LocalizedKey = LocalizedKey("history.mask_feather"); pub const MASK_MORPH: LocalizedKey = LocalizedKey("history.mask_morph"); pub const MASK_OPACITY: LocalizedKey = LocalizedKey("history.mask_opacity"); pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff"); pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology"); pub const MASK_REFINE: LocalizedKey = LocalizedKey("history.mask_refine"); /// TRACES: FR-DEV-10 /// A range mask's band moved — its bounds, its softness, or its hue arc. pub const MASK_RANGE: LocalizedKey = LocalizedKey("history.mask_range"); /// TRACES: FR-DEV-19b /// A stroke, named for what the hand did rather than for the part it /// landed in: a photographer takes back the mark they made, and which /// selection was holding it is not what they were thinking about. pub const MASK_PAINTED: LocalizedKey = LocalizedKey("history.mask_painted"); pub const MASK_ERASED: LocalizedKey = LocalizedKey("history.mask_erased"); /// TRACES: FR-DEV-19a /// A selection joined to a mask, taken out of it, or joined the other way. pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added"); pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed"); pub const MASK_PART_TOGGLED: LocalizedKey = LocalizedKey("history.mask_part_toggled"); pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined"); /// TRACES: FR-DEV-8 /// A repair's own settings changed — its radius, its source, its opacity. pub const SPOT: LocalizedKey = LocalizedKey("history.spot"); pub const SPOT_PLACED: LocalizedKey = LocalizedKey("history.spot_placed"); pub const SPOT_MOVED: LocalizedKey = LocalizedKey("history.spot_moved"); pub const SPOT_REMOVED: LocalizedKey = LocalizedKey("history.spot_removed"); /// Every step above, plus the two the core names for itself. /// /// Exists so the catalogue can be checked against the keys that are /// actually recorded rather than against a second copy of them. A new step /// left out of this list is a step with no test — which is the failure /// this whole arrangement is guarding, so it is worth saying plainly: /// **add the constant to this slice.** /// /// Test-only, because checking is the whole of what it is for. Resolving a /// key at runtime goes through [`super::resolve`] one key at a time and /// never needs the roll. #[cfg(test)] pub const ALL: &[LocalizedKey] = &[ dr_pipeline::history::OPENED, dr_pipeline::history::UNNAMED, PASTE, SNAPSHOT_RESTORED, FILM, SAMPLED_NEUTRAL, RESET_ALL, RESET_OP, RESET_PARAM, RESET_FRAMING, ROTATE, FLIP_H, FLIP_V, MASK_ADDED, MASK_REMOVED, MASK_TOGGLED, MASK_INVERTED, MASK_MOVED, MASK_FEATHER, MASK_MORPH, MASK_OPACITY, MASK_FALLOFF, MASK_MORPHOLOGY, MASK_REFINE, MASK_RANGE, MASK_PAINTED, MASK_ERASED, MASK_PART_ADDED, MASK_PART_REMOVED, MASK_PART_TOGGLED, MASK_JOINED, SPOT, SPOT_PLACED, SPOT_MOVED, SPOT_REMOVED, ]; } /// Resolve a key, deriving a fallback where none is catalogued. pub fn resolve(key: &str) -> String { catalogued(key).map_or_else(|| derive(key), str::to_string) } /// The label this build has actually chosen for `key`, if it has chosen one. /// /// Split out from [`resolve`] so that "is this catalogued?" can be asked, and /// it is asked by the test that guards the history rows. Those are read as a /// column of short phrases naming decisions, and `derive` is not up to that /// job even when it produces real words: it turns `history.mask_toggled` into /// "Mask Toggled", which is a description of a field rather than of something /// the photographer did — and, being perfectly readable, is a mistake nobody /// would look at twice. /// /// Deriving stays right for `op.` and `param.`, where an operation added as a /// YAML declaration must show up usable before anyone writes its translation /// (FR-DEV-3c). The difference is that those keys name a thing and these name /// an act. fn catalogued(key: &str) -> Option<&'static str> { Some(match key { // Operations // The attribute names. Short on purpose: these are read as a strip // of tabs, where a long word crowds out the next one. "attr.tone" => "Light", "attr.colour" => "Colour", "attr.detail" => "Detail", "attr.optics" => "Optics", "attr.compose" => "Compose", "attr.effect" => "Effects", "op.white_balance" => "White Balance", "op.exposure" => "Exposure", "op.highlights_shadows" => "Highlights & Shadows", "op.blacks_whites" => "Blacks & Whites", "op.brilliance" => "Brilliance", "op.vibrance" => "Vibrance", "op.saturation" => "Saturation", "op.colour_mixer" => "Colour Mixer", "op.colour_grading" => "Colour Grading", // "Sharpening" rather than what `derive` would make of the id. The id // says *capture* sharpening to separate it from the output sharpening // an export applies (FR-EXP-4), which is a distinction about where in // the pipeline it sits; in the develop panel there is only one, and // "Capture Sharpen" would name a distinction the photographer cannot // see from there. "op.capture_sharpen" => "Sharpening", "op.dehaze" => "Dehaze", "op.framing" => "Crop & Rotate", // "Lens Vignetting", not the "Vignetting" `derive` would produce, and // the qualifier is doing real work. This operation corrects the corner // falloff a lens imposed; the creative vignette that darkens corners on // purpose is a different operation, belongs to `Effect` rather than // `Optics`, and will want the plain word when it exists. Naming this // one for what it corrects means the two can sit in the same panel // without either having to be renamed to make room. "op.vignetting" => "Lens Vignetting", // "Chromatic Aberration", not the "Aberration" `derive` would give. // The bare word names a whole family of lens defects — coma, spherical, // astigmatism — and this operation corrects exactly one of them. Under // it sit sliders called "Red" and "Blue", which only make sense once // the heading has said what is being separated. "op.aberration" => "Chromatic Aberration", // The switch that accepts or declines the measured profile for the // lens the file names. "Lens Profile" rather than "Apply Lens Profile": // it is a tick box, and a checkbox labelled with a verb reads as a // button that does something once rather than a state that is on. "op.lens_profile" => "Lens Profile", // The view transform (FR-DEV-3j). "Tone Mapping" rather than the // "View Transform" `derive` would give: the id names where it sits in // the pipeline, and the photographer is choosing how the scene's range // is fitted onto the screen. "op.view_transform" => "Tone Mapping", // Parameters // Named for what it does rather than what it is, since a lone // parameter is titled by its operation and this one never reaches the // panel under its own name — see `rows_filtered`. "param.lens_profile.apply" => "Apply", "param.view_transform.contrast" => "Contrast", // In stops above middle grey: where the scene reaches display white. "param.view_transform.white" => "White Point", "param.temperature" => "Temperature", "param.tint" => "Tint", "param.exposure" => "Exposure", "param.highlights" => "Highlights", "param.shadows" => "Shadows", "param.blacks" => "Blacks", "param.whites" => "Whites", "param.brilliance" => "Brilliance", "param.vibrance" => "Vibrance", "param.saturation" => "Saturation", // What a faceted parameter adjusts — the mixer's three channels. // // Spelled out rather than left to `derive`, which would give "Sat" and // "Lum" from the keys. These name a run of twelve rows apiece, and an // abbreviation at the head of a section is a word the reader has to // expand every time they scan past it. "param.channel.hue" => "Hue", "param.channel.sat" => "Saturation", "param.channel.lum" => "Luminance", // The tone curve's four curves, which its points are *subject* to. // // Catalogued rather than derived because the master curve's key would // otherwise read "Rgb": these are the terms of a four-way choice, and // one of them miscapitalised is the one the eye goes to. The three // colours would derive correctly and are written out beside it anyway, // since a list where one entry is translated and three are guessed is // the shape a half-finished translation takes. "channel.rgb" => "RGB", "channel.red" => "Red", "channel.green" => "Green", "channel.blue" => "Blue", // The hue bands, which a faceted row is *subject* to. // // Catalogued even where `derive` would produce the same word, because // three of them are not the word the key spells: "spring" is spring // green, and reading "Spring" beside "Green" in a list of colours says // nothing. These are also the only place a swatch's meaning is written // down in words, which is what a photographer who cannot separate the // squares by eye has to go on. "band.red" => "Red", "band.orange" => "Orange", "band.yellow" => "Yellow", "band.chartreuse" => "Yellow-Green", "band.green" => "Green", "band.spring" => "Blue-Green", "band.cyan" => "Cyan", "band.azure" => "Azure", "band.blue" => "Blue", "band.violet" => "Violet", "band.magenta" => "Magenta", "band.rose" => "Rose", // Framing. "Straighten" rather than "Angle" because that is the task // the control performs; the number it reports is still degrees. "param.angle" => "Straighten", "param.rotation" => "Rotate", "param.flip_h" => "Flip Horizontal", "param.flip_v" => "Flip Vertical", "param.crop_x" => "Crop Left", "param.crop_y" => "Crop Top", "param.crop_w" => "Crop Width", "param.crop_h" => "Crop Height", // TRACES: FR-DEV-20 // The panel labels these "Vertical" and "Horizontal" under the // straightening, where the context says what they correct; a history // row has no such context, so it says it. "param.keystone_v" => "Vertical Perspective", "param.keystone_h" => "Horizontal Perspective", // TRACES: FR-DEV-5 // The steps that are not a parameter moving. // // Catalogued rather than derived because these are read as a *column* // of short phrases, where `derive` would give "Mask Added" and "Reset // Op" — the second of which names a function rather than an action, // and the first of which reads as a label for a thing rather than for // something the photographer did. A history is a list of decisions, so // the rows are written as decisions. // // A step naming an operation or a parameter is *not* here: it resolves // through the same `op.` and `param.` entries above that the panel // uses, which is what lets an operation added as a YAML declaration // appear in the history correctly named with no entry written for it // (FR-DEV-3c). "history.opened" => "Opened", "history.edit" => "Edit", "history.paste" => "Paste Settings", "history.snapshot_restored" => "Restore A Snapshot", "history.film" => "Film Stock", "history.sampled_neutral" => "Sample Neutral", "history.reset_all" => "Reset Everything", "history.reset_op" => "Reset Group", "history.reset_param" => "Reset Control", "history.reset_framing" => "Reset Crop & Rotate", "history.rotate" => "Rotate", "history.flip_h" => "Flip Horizontal", "history.flip_v" => "Flip Vertical", "history.mask_added" => "Add Mask", "history.mask_removed" => "Delete Mask", "history.mask_toggled" => "Mask On/Off", "history.mask_inverted" => "Invert Mask", "history.mask_moved" => "Move Mask", "history.mask_feather" => "Mask Feather", "history.mask_morph" => "Mask Edge", "history.mask_opacity" => "Mask Opacity", "history.mask_falloff" => "Mask Falloff", "history.mask_morphology" => "Mask Grow/Shrink", "history.mask_refine" => "Mask Refine", "history.mask_range" => "Mask Range", "history.mask_painted" => "Paint Mask", "history.mask_erased" => "Erase Mask", "history.mask_part_added" => "Add To Mask", "history.mask_part_removed" => "Remove From Mask", "history.mask_part_toggled" => "Show Or Hide Part Of A Mask", "history.mask_joined" => "Change How A Mask Joins", "history.spot" => "Adjust Repair", "history.spot_placed" => "Add Repair", "history.spot_moved" => "Move Repair", "history.spot_removed" => "Delete Repair", _ => return None, }) } /// Turn `op.some_new_thing` into `Some New Thing`. /// /// A missing translation should look like an untranslated label, not like a /// bug — a blank control is far harder to diagnose than an oddly-capitalised /// one. fn derive(key: &str) -> String { let tail = key.rsplit('.').next().unwrap_or(key); let mut out = String::with_capacity(tail.len()); let mut capitalise = true; for ch in tail.chars() { if ch == '_' || ch == '-' { out.push(' '); capitalise = true; } else if capitalise { out.extend(ch.to_uppercase()); capitalise = false; } else { out.push(ch); } } out } #[cfg(test)] mod tests { use super::*; #[test] fn every_step_the_interface_records_has_a_name() { // A history is only as useful as its rows are distinguishable, and // these are the ones no descriptor can name — the resets, the flips, // the mask actions. A key missing from the catalogue derives something // readable rather than nothing, so the failure this guards is subtler: // two different steps deriving the *same* word, or a word that names // the function that ran rather than the decision the user made. let mut seen: Vec = Vec::new(); for key in step::ALL.iter().map(|k| k.0) { let label = catalogued(key).unwrap_or_else(|| { panic!( "{key} is not catalogued; it would fall through to `derive` \ and read as {:?}, which names a field rather than an act", derive(key) ) }); assert!(!label.is_empty(), "{key} resolved to nothing"); assert!( !seen.contains(&label.to_string()), "{key} shares the label {label:?} with an earlier step" ); seen.push(label.to_string()); } } #[test] fn a_step_naming_an_operation_borrows_the_panels_label() { // What keeps the history honest as the pipeline grows: a step that // moved a parameter is named by the same entry the slider beside it // is, so the two can never disagree, and an operation nobody has // catalogued yet still reads correctly in both places. assert_eq!(resolve("op.white_balance"), "White Balance"); assert_eq!(resolve("op.tone_curve"), "Tone Curve"); assert_eq!(resolve("param.exposure"), "Exposure"); } #[test] fn catalogued_keys_resolve_to_their_label() { assert_eq!(resolve("op.white_balance"), "White Balance"); assert_eq!(resolve("param.highlights"), "Highlights"); // Catalogued precisely because `derive` would get it wrong: the id // carries a distinction ("capture", as against an export's output // sharpening) that belongs in the pipeline and not on a panel. assert_eq!(resolve("op.capture_sharpen"), "Sharpening"); // Its parameters are the opposite case — the derived words are the // right words, so they are left uncatalogued and shared with whatever // asks for an amount or a radius next. assert_eq!(resolve("param.amount"), "Amount"); assert_eq!(resolve("param.radius"), "Radius"); assert_eq!(resolve("param.threshold"), "Threshold"); } #[test] fn an_uncatalogued_key_derives_a_readable_label() { // The FR-DEV-3c property: a new operation shows up usable before // anyone writes its translation. assert_eq!(resolve("op.tone_curve"), "Tone Curve"); assert_eq!(resolve("param.midpoint"), "Midpoint"); } #[test] fn a_key_without_a_prefix_still_resolves() { assert_eq!(resolve("clarity"), "Clarity"); } #[test] fn no_key_resolves_to_empty() { // An empty label renders as a control with no name, which reads as a // rendering bug rather than a missing translation. for key in ["", "op.", "x", "op.a_b_c"] { let got = resolve(key); if key.is_empty() || key == "op." { // Degenerate input; only the non-degenerate cases must be // non-empty. continue; } assert!(!got.is_empty(), "{key} resolved to nothing"); } } }