Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a mistake just noticed. It is the wrong instrument for one noticed six adjustments later: eight presses, each changing the picture, with no way to see how far back the mistake is without passing through it. A step is a whole state, so arriving from six away costs what arriving from one does — which is what makes a row worth making clickable rather than decorative. `Edit::Discrete` had to go for the list to be worth drawing. Seventeen call sites recorded the same anonymous step, which is fine for deciding whether two changes are one gesture and useless for a panel: seventeen rows reading "Discrete" is not a history. Every variant now carries enough to name itself, and the compiler enumerated the sites that had to start saying so. A step that moved a parameter is still named out of the descriptor, so an operation added as a YAML declaration appears in the history correctly named with nothing written for it (FR-DEV-3c). Choosing a film stock was not undoable at all. The pick went straight to `choose_film`, which nothing on the history's path ever sees. `pick_film` records it, and is separate because the same call is also how a *restored* edit gets its tables back — recording that would push a step for the undo the photographer had just asked for. The list is rebuilt off a revision rather than off every redraw. A drag ends in a redraw per frame while folding into one step, so the unconditional version would tear down and recreate every row sixty times a second to arrive back at the list already on screen. The counter is process-wide: a per-instance one starts every photograph at the same number, so a frontend holding "the revision I last drew" would keep the previous image's steps on screen — invisible while every image opens with one identical row, and a wrong-photograph bug the moment persisted history means it does not. The step names that no descriptor can supply are constants with a roll, and a test walks the roll rather than a second copy of it. `resolve` splits so that "is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into "Mask Toggled", which names a field rather than an act and, being perfectly readable, is a mistake nobody would look at twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
371 lines
16 KiB
Rust
371 lines
16 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");
|
|
|
|
/// 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,
|
|
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.geometry" => "Geometry",
|
|
"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",
|
|
// "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.framing" => "Crop & Rotate",
|
|
|
|
// 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.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");
|
|
}
|
|
}
|
|
}
|