The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.
Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
450 lines
20 KiB
Rust
450 lines
20 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");
|
|
/// 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,
|
|
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",
|
|
|
|
// 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.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.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");
|
|
}
|
|
}
|
|
}
|