A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone.
436 lines
20 KiB
Rust
436 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-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_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,
|
|
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_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-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.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_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");
|
|
}
|
|
}
|
|
}
|