Give the controls a vocabulary, and let a node ask for one

widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:21:35 +02:00
co-authored by Claude Opus 5
parent e7130ff891
commit 0a331c717e
10 changed files with 1662 additions and 910 deletions
+43 -1
View File
@@ -586,9 +586,51 @@ fn param_ctor(
max,
)
}
// A fixed list of named alternatives. The value is the chosen index,
// so the range is the list's own bounds and a node declaring one needs
// no `min:`/`max:` of its own.
//
// Variants are localisation keys, like every other label in a node —
// the core never holds a display string (NFR-A11Y-1). The default is
// always the first, so `reset` means the same thing here as everywhere
// else; a node whose neutral choice is not first has listed them in
// the wrong order.
"enum" => {
let variants = spec
.get("variants")
.and_then(Value::as_sequence)
.ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?;
if variants.len() < 2 {
return Err(format!(
"`{ctx}.variants` lists {} choice(s); a control the user \
cannot change is not a control",
variants.len()
));
}
let keys = variants
.iter()
.enumerate()
.map(|(i, v)| {
as_str(v, &format!("{ctx}.variants[{i}]"))
.map(|k| format!("LocalizedKey({k:?})"))
})
.collect::<Result<Vec<_>, _>>()?;
(
format!(
"ParamDescriptor::choice({:?}, {:?}, &[{}])",
id,
label,
keys.join(", ")
),
0.0,
0.0,
(variants.len() - 1) as f64,
)
}
other => {
return Err(format!(
"`{ctx}.kind` is `{other}`; expected stops, amount, switch, fraction or scalar"
"`{ctx}.kind` is `{other}`; expected stops, amount, switch, \
fraction, scalar or enum"
))
}
})
+161 -12
View File
@@ -59,23 +59,91 @@ pub enum Unit {
/// A control that does not reduce to a slider or a switch.
///
/// The core names the *kind* of widget; `dr-widgets` owns what it looks like
/// and how it behaves (ARCH §3.3). This is deliberately a small closed
/// enum rather than an open string: a UI must be able to match exhaustively
/// and know it has covered everything the core can ask for.
/// The core names the *kind* of widget; the frontend owns what it looks like
/// and how it behaves (ARCH §3.3). This is deliberately a small closed enum
/// rather than an open string: a UI must be able to match exhaustively and
/// know it has covered everything the core can ask for.
///
/// **Every one of these is a hint over ordinary scalar parameters**, never a
/// new kind of value. A curve is its point coordinates, a colour wheel is
/// three numbers, a crop is four edges — all [`ParamKind::Scalar`], all
/// individually addressable, all persisted by the sidecar with no special
/// case. That is what makes the fallback in [`Presentation::widgets`] honest:
/// a frontend that implements none of these still renders every parameter as
/// a slider and the edit works, merely more tediously.
///
/// It is also why there is no `Colour` *kind*. A colour is three or four
/// numbers, and introducing a value type that is not an `f32` would reach
/// through the graph, the uniform block and the sidecar format to buy a
/// control that [`ColourWheel`](WidgetKind::ColourWheel) already describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidgetKind {
/// A tone curve, edited by dragging points on a grid.
///
/// The underlying parameters are ordinary [`ParamKind::Scalar`]s — the
/// point coordinates — so a UI that does not implement the curve widget
/// can still present them as sliders and remain fully functional. That
/// fallback is the reason the points are scalars rather than an opaque
/// blob.
Curve,
/// The underlying parameters are the point coordinates, x and y
/// interleaved.
ToneCurve,
/// Colour grading wheels: a hue-and-strength pad per tonal range.
ColourWheel,
/// On-canvas crop and straighten handles. Parameters are the four crop
/// edges as fractions, and the straighten angle.
CropOverlay,
/// On-canvas placement of a linear or radial mask.
GradientHandle,
/// On-canvas brush strokes.
BrushMask,
/// An eyedropper bound to the canvas, setting white balance from a pixel.
WhitePoint,
}
impl WidgetKind {
/// TRACES: FR-UI-7
/// Whether this widget is manipulated on the photograph rather than in a
/// panel.
///
/// A property of the widget itself, not of the screen: a crop is dragged
/// on the image wherever the image is, and that is true of a phone and a
/// workstation alike. Where the panel then *puts* the affordance that
/// turns it on, and how large its handles are, stay frontend decisions
/// (ARCH §4.3a).
pub fn is_on_canvas(self) -> bool {
matches!(
self,
Self::CropOverlay | Self::GradientHandle | Self::BrushMask | Self::WhitePoint
)
}
}
/// TRACES: FR-DEV-3a
/// What a widget inherently needs in order to be usable.
///
/// **Demands describe the control, not the screen** (ARCH §4.3a). A curve
/// needs two-dimensional pointing and a certain amount of room to be worth
/// drawing at all; those are facts about curves. Whether *this* window has
/// that room, at what breakpoint, on what platform, is the frontend's
/// question, and a demand carrying pixels or a platform name would be the core
/// answering it — a core that reasons about pixels will eventually be wrong
/// about a display it never saw.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WidgetDemand {
/// Needs the value to be dragged in two dimensions at once. A frontend
/// with only a keyboard, or a strictly linear input, should skip it.
pub two_dimensional: bool,
/// Needs pointing accurate to a small fraction of the control. A frontend
/// driving a television with a remote should skip it; touch is fine, since
/// hit regions grow to the modality (FR-UI-7).
pub precise_pointing: bool,
}
/// The shape of a parameter's value.
///
/// **Every variant is carried as an `f32`.** That is not an implementation
/// detail to be tidied away later — it is what lets one storage path serve
/// every parameter: the sidecar writes a number, the uniform block takes a
/// number, and `set_param` is one function rather than one per shape. A
/// variant that needed a richer value would reach through all three, which is
/// why a colour is a [`WidgetKind::ColourWheel`] over three scalars rather
/// than a kind of its own.
#[derive(Debug, Clone, PartialEq)]
pub enum ParamKind {
Scalar {
@@ -86,6 +154,24 @@ pub enum ParamKind {
precision: u8,
},
Bool,
/// TRACES: FR-DEV-3a
/// One of a short, fixed list of named alternatives.
///
/// The value is the chosen variant's **index**, held as an `f32` like
/// everything else — small integers are exact in binary32, so this costs
/// nothing in fidelity and keeps the parameter on the ordinary storage
/// path.
///
/// Distinct from a `Scalar` running 0..n because the numbers are not on a
/// scale: interpolating between two of them is meaningless, dragging
/// through them is not a gesture anyone wants, and the labels are the
/// whole point. A UI that treated this as a scalar would render a slider
/// reading "2" where the user needs to see "Bicubic".
Enum {
/// In index order. The label is a localisation key, resolved by the
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
variants: &'static [LocalizedKey],
},
}
/// TRACES: FR-DEV-3a | FR-DEV-3b
@@ -105,14 +191,44 @@ pub enum ParamKind {
/// operations, which want plain sliders, say nothing at all.
#[derive(Debug, Clone, PartialEq)]
pub struct Presentation {
pub widget: WidgetKind,
/// The parameters this widget owns, in the order it expects them.
/// Widgets that would draw these parameters, **in descending order of
/// preference** (ARCH §4.3a).
///
/// The frontend walks the list and takes the first it both implements and
/// can afford. Falling off the end is not an error: every parameter
/// remains an individually addressable scalar, so plain sliders are always
/// the final fallback and the edit still works.
///
/// A list rather than one kind because the alternatives are real. A colour
/// grading operation is best as a wheel, acceptable as a hue-and-strength
/// pair of sliders, and an operation that can say so gets a good control
/// on a workstation and a usable one on a phone without the core knowing
/// which it is talking to.
pub widgets: &'static [WidgetKind],
/// What the preferred widget needs in order to be worth drawing.
///
/// Applies to the list as a whole rather than per entry: a frontend that
/// cannot meet the demand skips to plain sliders, which is the same answer
/// it gives for a widget it has not implemented.
pub demand: WidgetDemand,
/// The parameters these widgets own, in the order they expect them.
///
/// Parameters absent from this list are presented normally, so an
/// operation can pair a curve with an ordinary strength slider.
pub params: &'static [ParamId],
}
impl Presentation {
/// The first widget in the preference list that `supported` accepts.
///
/// The walk lives here rather than in each frontend so that "first
/// supported, else sliders" is written once and cannot drift between a
/// desktop UI, a test harness and whatever consumes capabilities next.
pub fn choose(&self, supported: impl Fn(WidgetKind) -> bool) -> Option<WidgetKind> {
self.widgets.iter().copied().find(|w| supported(*w))
}
}
/// TRACES: FR-DEV-3a
/// A parameter's place in an operation whose parameters form a grid.
///
@@ -206,6 +322,26 @@ impl ParamDescriptor {
}
}
/// One of a fixed list of alternatives, defaulting to the first.
///
/// The first rather than a caller-chosen index, so the reset contract
/// holds the way it does for every other kind: index 0 is the neutral
/// choice, and an operation whose default is not its first variant has
/// listed them in the wrong order.
pub const fn choice(
id: &'static str,
label: &'static str,
variants: &'static [LocalizedKey],
) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Enum { variants },
default: 0.0,
facet: None,
}
}
/// A 0…1 fraction — a proportion of something, rather than an amount.
///
/// Its own constructor because the crop rect needs four of them and the
@@ -299,6 +435,19 @@ impl ParamDescriptor {
0.0
}
}
// Rounded before clamping, because the value arriving here is an
// `f32` that has been through a sidecar and possibly a slider: an
// index of 1.9999 is variant 2, and truncating it to 1 would
// silently select the wrong option. A non-finite index falls back
// to the default for the same reason a scalar does.
ParamKind::Enum { variants } => {
if value.is_finite() {
let last = variants.len().saturating_sub(1) as f32;
value.round().clamp(0.0, last)
} else {
self.default
}
}
}
}
}
+25
View File
@@ -447,6 +447,24 @@ mod tests {
);
}
ParamKind::Bool => {}
ParamKind::Enum { variants } => {
// An empty list is a control with nothing to pick, and
// a one-entry list is a control that cannot be
// changed — both are declaration mistakes rather than
// states a UI should try to render.
assert!(
variants.len() > 1,
"{}.{} offers fewer than two choices",
cap.id,
p.id
);
assert!(
p.default >= 0.0 && p.default < variants.len() as f32,
"{}.{} defaults to a variant that does not exist",
cap.id,
p.id
);
}
}
}
}
@@ -536,6 +554,13 @@ mod tests {
c.label.0, p.label.0, p.value
),
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
ParamKind::Enum { variants } => format!(
"{}/{}: choice of {} = {}",
c.label.0,
p.label.0,
variants.len(),
p.value
),
})
})
.collect();
+6 -1
View File
@@ -41,7 +41,7 @@ pub mod sidecar;
pub use descriptor::{
Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
Scale, Unit, WidgetKind,
Scale, Unit, WidgetDemand, WidgetKind,
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
@@ -81,6 +81,11 @@ mod tests {
}
}
ParamKind::Bool => 1.0 - p.default,
// The last variant, so the choice differs from the
// default whatever the list holds. A single-variant enum
// cannot be moved off its default and is correctly left
// where it is.
ParamKind::Enum { variants } => variants.len().saturating_sub(1) as f32,
};
g.set_param(desc.id, p.id, v);
}
+19 -3
View File
@@ -33,7 +33,7 @@
use crate::descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit,
WidgetKind,
WidgetDemand, WidgetKind,
};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
@@ -293,7 +293,16 @@ impl Operation for ToneCurve {
fn presentation(&self) -> Option<Presentation> {
Some(Presentation {
widget: WidgetKind::Curve,
// One entry: there is no second way to draw a tone curve that is
// better than the sliders the frontend falls back to anyway.
widgets: &[WidgetKind::ToneCurve],
demand: WidgetDemand {
// A point is dragged in x and y together — that is what a
// curve *is*, and a frontend that can only move one axis at a
// time is better off with the point coordinates as sliders.
two_dimensional: true,
precise_pointing: true,
},
params: &CURVE_PARAMS,
})
}
@@ -614,7 +623,14 @@ mod tests {
// If the presentation misses one, that slider appears twice: once in
// the curve and once as a stray control beneath it.
let presentation = ToneCurve::new().presentation().expect("declares a widget");
assert_eq!(presentation.widget, WidgetKind::Curve);
assert_eq!(presentation.widgets, &[WidgetKind::ToneCurve]);
// A frontend that implements the curve gets it; one that implements
// nothing falls through to sliders rather than to an error.
assert_eq!(
presentation.choose(|w| w == WidgetKind::ToneCurve),
Some(WidgetKind::ToneCurve)
);
assert_eq!(presentation.choose(|_| false), None);
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
for p in DESCRIPTOR.params {
assert!(