Files
DarkRoom/ui/dr-ui/src/labels.rs
T
dtourolle 68ebf5d78b Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
2026-09-06 19:01:48 +02:00

395 lines
17 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");
pub const FILM: LocalizedKey = LocalizedKey("history.film");
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-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,
FILM,
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,
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",
// Parameters
"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-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.film" => "Film Stock",
"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.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");
}
}
}