Let an operation say what it is about, so the panel can group without naming

Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.

`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.

The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.

Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.

Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.

Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
This commit is contained in:
2026-08-22 10:10:00 +02:00
parent acd694c2cf
commit 7421837c8a
26 changed files with 385 additions and 17 deletions
+105
View File
@@ -452,18 +452,123 @@ impl ParamDescriptor {
}
}
/// What an operation *is about*.
///
/// A statement of the operation's nature, in the same category as
/// [`ParamKind`]: the core saying what a thing is, not where it is drawn. A
/// frontend may render these as tabs, as section headings, as a filter, or
/// ignore them entirely — that choice is composition and belongs to whoever
/// knows the window (ARCH §4.3a).
///
/// # Why the core may say this at all
///
/// The line §4.3a draws is between *what a thing is* and *what is drawn,
/// where it sits, how wide it is, and whether it is visible*. "White balance
/// is a colour operation" is the first kind. It is also knowledge the core is
/// uniquely placed to hold: the person adding an operation knows what it does,
/// and a frontend that had to work it out would be doing so by matching on the
/// operation's name — which is the one thing `ui/` may never do (FR-DEV-3a).
///
/// What this deliberately is **not** is a tab name. There is no `Attribute`
/// for "the third tab", the order below is declaration order rather than
/// screen order, and an operation carrying two attributes appears wherever the
/// frontend decides that means — twice, once, or nowhere.
///
/// # Plural on purpose
///
/// An operation may carry several. The tone curve is genuinely both tonal and
/// chromatic — it has an RGB curve and per-channel curves — and forcing it to
/// pick one would file it away from half the people looking for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Attribute {
/// Lightness and its distribution: exposure, contrast, the recovery
/// controls, the tone curve.
Tone,
/// Hue and saturation: white balance, the colour mixer, vibrance.
Colour,
/// Acutance and noise — what the image is made of at the pixel level.
/// Sharpening, noise reduction, texture, clarity.
Detail,
/// Corrections for the lens that took the photograph: distortion,
/// chromatic aberration, vignetting.
Optics,
/// The shape of the frame: crop, straighten, rotation, flips.
Geometry,
/// Applied rather than corrected — a look, not a fix.
Effect,
}
impl Attribute {
/// Every attribute, in declaration order.
///
/// Declaration order is roughly the order a photographer works in, which
/// makes it a reasonable *default* for a frontend that wants one. It is
/// not a screen order: nothing here obliges a frontend to show them all,
/// show them in this sequence, or show them at all.
pub const ALL: [Attribute; 6] = [
Attribute::Tone,
Attribute::Colour,
Attribute::Detail,
Attribute::Optics,
Attribute::Geometry,
Attribute::Effect,
];
/// The localisation key naming this concept.
///
/// Naming the concept, the way an operation's own `label` names the
/// operation. What a frontend *does* with the name — a tab, a heading,
/// nothing — is still its own affair.
pub fn label(self) -> LocalizedKey {
LocalizedKey(match self {
Self::Tone => "attr.tone",
Self::Colour => "attr.colour",
Self::Detail => "attr.detail",
Self::Optics => "attr.optics",
Self::Geometry => "attr.geometry",
Self::Effect => "attr.effect",
})
}
/// Parse the name used in `ops/<id>.yaml`.
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"tone" => Self::Tone,
"colour" => Self::Colour,
"detail" => Self::Detail,
"optics" => Self::Optics,
"geometry" => Self::Geometry,
"effect" => Self::Effect,
_ => return None,
})
}
}
/// The static description of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct OpDescriptor {
pub id: OpId,
pub label: LocalizedKey,
pub params: &'static [ParamDescriptor],
/// What this operation is about (ARCH §4.3a).
///
/// **Never empty**, and `build.rs` refuses to generate an operation that
/// declares none. An operation with no attribute would be invisible to a
/// frontend that filters by them, and a control that silently does not
/// exist is a worse failure than a build that stops — particularly when
/// the cause would be a missing line in a YAML file nobody looked at.
pub attributes: &'static [Attribute],
}
impl OpDescriptor {
pub fn param(&self, id: ParamId) -> Option<&ParamDescriptor> {
self.params.iter().find(|p| p.id == id)
}
/// Whether this operation is about `attribute`.
pub fn has(&self, attribute: Attribute) -> bool {
self.attributes.contains(&attribute)
}
}
#[cfg(test)]