Files
DarkRoom/ui/dr-ui/src/labels.rs
T
2026-08-22 19:04:13 +02:00

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");
}
}
}