Files
DarkRoom/ui/dr-ui/src/labels.rs
T
dtourolle 8294e6b59f Call the reference curve what it is, and drop wording that reads as copying
The view transform's second curve is the DNG SDK's published reference
rendering — the ACR3 default curve applied by RefBaselineRGBTone — so
it is the "DNG Reference" curve in the panel, D21 and the code, not a
name borrowed from another product. Comments and docs that justified a
choice by another editor doing it ("as their Amount", "so a
photographer arriving from it finds the name") now give the actual
reason. The Vivid presets no longer describe themselves as reaching
for another editor's look; they are DarkRoom's own.

Factual mentions stay: which program wrote the library's DNGs, what
was measured against, and preset import. camera-profiles.md gains §15,
on starting a photograph from the edit it already carries.
2026-10-03 14:22:49 -04:00

477 lines
22 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.
/// 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");
/// TRACES: FR-DEV-6
/// The edit a photograph already carried, brought across on first opening.
pub const EARLIER_EDIT: LocalizedKey = LocalizedKey("history.earlier_edit");
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,
EARLIER_EDIT,
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",
"op.learned_denoise" => "AI Denoise",
// 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",
// The DNG camera profile's tables (D20). "Camera Profile", the DNG
// specification's own name for what the file carries.
"op.camera_profile" => "Camera Profile",
// 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.learned_denoise.apply" => "Apply",
"param.learned_denoise.grain" => "Keep grain",
"param.camera_profile.apply" => "Use Profile",
// The LookTable's strength.
"param.camera_profile.look" => "Look Amount",
"param.view_transform.contrast" => "Contrast",
// In stops above middle grey: where the scene reaches display white.
"param.view_transform.white" => "White Point",
// The view transform's curve (D21): the DNG SDK's reference
// rendering, or D19's sigmoid with its highlight shoulder.
"param.view_transform.curve" => "Curve",
"param.view_transform.curve.camera_raw" => "DNG Reference",
"param.view_transform.curve.sigmoid" => "Sigmoid",
"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.earlier_edit" => "Earlier Edit",
"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<String> = 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");
}
}
}