184 lines
7.7 KiB
Rust
184 lines
7.7 KiB
Rust
//! 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(),
|
|
// "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".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");
|
|
// 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");
|
|
}
|
|
}
|
|
}
|