Files
DarkRoom/ui/dr-ui/src/labels.rs
T
dtourolleandClaude Opus 5 13d003b89d Let the curve widget plot whichever curve is asked for
An operation with four curves and a panel that draws one plot needs a way to
say which. The panel finds out the way it finds out everything else: the
points are faceted with the subject they act on, consecutive parameters
sharing a subject are one curve, and a widget spanning several of them gets
a selector over their names. Nothing in ui/ contains the word "red", and an
operation that grows a fifth curve arrives with a fifth chip.

The names ride on the panel rather than on the curve's row, because a Slint
model is compared by identity: a fresh list built on every parameter event
would make the row look changed every time, and rewriting a row rebuilds the
element holding the drag in progress. That is the hazard the in-place point
update already exists to avoid.

Which curve is on show is interface state, not an edit. It changes no pixel,
so it takes no history step, reaches no sidecar, and redraws nothing — the
photograph on screen is already right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:06:21 +02:00

167 lines
6.6 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(),
"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");
}
}
}