//! 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. /// Resolve a key, deriving a fallback where none is catalogued. pub fn resolve(key: &str) -> String { 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".into(), "attr.colour" => "Colour".into(), "attr.detail" => "Detail".into(), "attr.optics" => "Optics".into(), "attr.geometry" => "Geometry".into(), "attr.effect" => "Effects".into(), "op.white_balance" => "White Balance".into(), "op.exposure" => "Exposure".into(), "op.highlights_shadows" => "Highlights & Shadows".into(), "op.blacks_whites" => "Blacks & Whites".into(), "op.brilliance" => "Brilliance".into(), "op.vibrance" => "Vibrance".into(), "op.saturation" => "Saturation".into(), "op.colour_mixer" => "Colour Mixer".into(), "op.framing" => "Crop & Rotate".into(), // Parameters "param.temperature" => "Temperature".into(), "param.tint" => "Tint".into(), "param.exposure" => "Exposure".into(), "param.highlights" => "Highlights".into(), "param.shadows" => "Shadows".into(), "param.blacks" => "Blacks".into(), "param.whites" => "Whites".into(), "param.brilliance" => "Brilliance".into(), "param.vibrance" => "Vibrance".into(), "param.saturation" => "Saturation".into(), // 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".into(), "param.channel.sat" => "Saturation".into(), "param.channel.lum" => "Luminance".into(), // 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".into(), "channel.red" => "Red".into(), "channel.green" => "Green".into(), "channel.blue" => "Blue".into(), // 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".into(), "band.orange" => "Orange".into(), "band.yellow" => "Yellow".into(), "band.chartreuse" => "Yellow-Green".into(), "band.green" => "Green".into(), "band.spring" => "Blue-Green".into(), "band.cyan" => "Cyan".into(), "band.azure" => "Azure".into(), "band.blue" => "Blue".into(), "band.violet" => "Violet".into(), "band.magenta" => "Magenta".into(), "band.rose" => "Rose".into(), // Framing. "Straighten" rather than "Angle" because that is the task // the control performs; the number it reports is still degrees. "param.angle" => "Straighten".into(), "param.rotation" => "Rotate".into(), "param.flip_h" => "Flip Horizontal".into(), "param.flip_v" => "Flip Vertical".into(), "param.crop_x" => "Crop Left".into(), "param.crop_y" => "Crop Top".into(), "param.crop_w" => "Crop Width".into(), "param.crop_h" => "Crop Height".into(), other => derive(other), } } /// 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 catalogued_keys_resolve_to_their_label() { assert_eq!(resolve("op.white_balance"), "White Balance"); assert_eq!(resolve("param.highlights"), "Highlights"); } #[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"); } } }