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
+134 -426
View File
@@ -9,6 +9,7 @@
import { Theme } from "theme.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint";
// One parameter, flattened for Slint's model system.
//
@@ -43,7 +44,7 @@ export struct ParamRow {
// Which control to build. Mirrors ParamKind, plus the widget kinds an
// operation can request through its presentation.
kind: string, // "scalar" | "bool" | "curve"
kind: string, // "scalar" | "bool" | "enum" | "curve"
// A run of rows inside a group, for an operation whose parameters form a
// grid rather than a list.
@@ -83,6 +84,14 @@ export struct ParamRow {
// `param-index` on a curve row is the index of the *first* point
// parameter, so a drag routes back by offsetting from it.
points: [float],
// Enum rows only: the variant names, in index order.
//
// Resolved in Rust against the UI's catalogue, like every other label
// here — the core publishes localisation keys and never a display string.
// `value` on such a row is the chosen index, which is why an enum needs no
// separate selection field.
choices: [string],
}
// The name of a group of controls, and what can be done to the group.
@@ -151,165 +160,6 @@ component GroupHeading inherits Rectangle {
}
}
// **The** slider. One track, one hit area, one set of gesture rules.
//
// This exists because there used to be two of these, written out separately —
// one reading its geometry from a `ParamRow` for the generated panel, one
// taking plain numbers for the straighten angle — with a comment claiming the
// duplication was safe because "the track behaviour is the same and
// deliberately so". It was not safe and did not stay the same: the moment the
// touch arbitration below was fixed in one copy, the two sliders in the same
// sidebar started behaving differently, and which one you got depended on which
// panel you happened to be dragging in. The wrappers below now differ only in
// where their numbers come from.
component SliderTrack inherits Rectangle {
in property <float> value;
in property <float> default-value;
in property <float> minimum;
in property <float> maximum;
callback changed(float);
callback reset();
/// The pointer is on this control. A scrolling ancestor listens so it can
/// stand down — see the note on `engaged` below.
callback engaged-changed(bool);
height: Theme.touch-target / 2;
// Guarded, because a descriptor with a zero range would otherwise divide
// by nothing and put every position at infinity.
property <float> span: max(0.000001, root.maximum - root.minimum);
// **Why hover, and not the drag itself.**
//
// A Flickable does not merely compete for a gesture, it *withholds* the
// press: `DelayForwarding` holds it back for 100ms and only delivers it if
// nothing has claimed the gesture by then. A finger that starts moving
// inside that window therefore leaves this TouchArea never pressed at all —
// so `moved` never fires, and any handler keyed on the drag having started
// can never run. That is why the panel kept taking sliders away from a
// finger while a tap worked perfectly: a tap's release arrives before the
// 100ms is up, so press and release are delivered together, and only a
// *drag* falls in the hole.
//
// Hover is the one signal that does get through. Move events are dispatched
// to children even while the press is withheld, so the moment a finger
// lands on a track and travels a pixel this goes true, the panel sets
// `interactive: false`, and the Flickable stops arbitrating before it can
// capture anything.
//
// The cost is that a drag *starting* on a track no longer scrolls the
// panel. The label above each track and the padding around it still do, and
// the wheel is unaffected — a Flickable handles wheel events whether or not
// it is interactive.
property <bool> engaged: area.has-hover || area.claimed;
changed engaged => { root.engaged-changed(root.engaged); }
// The rail.
Rectangle {
y: (parent.height - 3px) / 2;
height: 3px;
background: Theme.surface-raised;
border-radius: 1.5px;
}
// The default position, drawn only when it is not at an end. Hand-built
// rather than using the standard Slint slider so this can be marked at
// all — a symmetric control needs to show where zero is.
if root.minimum < root.default-value && root.default-value < root.maximum: Rectangle {
x: (root.default-value - root.minimum) / root.span * parent.width - 1px;
y: (parent.height - 9px) / 2;
width: 2px;
height: 9px;
background: Theme.rule;
}
// Fill from the default to the current value, so the control shows the
// size and direction of the adjustment rather than an absolute magnitude.
Rectangle {
property <length> default-x:
(root.default-value - root.minimum) / root.span * parent.width;
property <length> value-x:
(root.value - root.minimum) / root.span * parent.width;
x: min(self.default-x, self.value-x);
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
y: (parent.height - 3px) / 2;
height: 3px;
// The fill is the engaged part of the control — the span the
// photographer has actually moved — so it takes `active` rather than
// the ink the rest of the track is drawn in.
background: Theme.active;
border-radius: 1.5px;
}
Rectangle {
x: (root.value - root.minimum) / root.span * parent.width - 6px;
y: (parent.height - 12px) / 2;
width: 12px;
height: 12px;
border-radius: 6px;
background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim;
}
area := TouchArea {
// Explicitly fill the track. A TouchArea with no geometry collapses to
// zero and only reports the events that happen to land on it, which
// shows up as a slider that clicks but does not drag.
width: 100%;
height: 100%;
// Whether this gesture has been claimed as a slider drag.
//
// A scrolling panel acts vertically and this control acts
// horizontally, so the axis of the movement says which was meant.
// Committing on press-down instead — the obvious approach — makes
// every attempt to scroll from a slider jump its value first, which is
// destructive and happens constantly given how much of a panel is
// sliders.
property <bool> claimed: false;
function value-at(px: length) -> float {
return clamp(
root.minimum + (px / self.width) * root.span,
root.minimum,
root.maximum);
}
moved => {
// `moved` fires only while pressed, so this is a drag.
if (!self.claimed
&& abs(self.mouse-x - self.pressed-x)
> abs(self.mouse-y - self.pressed-y)) {
self.claimed = true;
}
if (self.claimed) {
root.changed(self.value-at(self.mouse-x));
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
self.claimed = false;
}
// Right-click resets, alongside double-click.
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
root.reset();
}
}
clicked => {
// A press with no meaningful drag: jump to it. Handled on release
// rather than on press so it cannot fire during a scroll that
// merely started here.
if (!self.claimed) {
root.changed(self.value-at(self.mouse-x));
}
}
double-clicked => { root.reset(); }
}
}
// A parameter's value, at the precision its descriptor declares.
//
// A global rather than the same ternary written into every control that shows
@@ -327,6 +177,13 @@ global Readout {
}
// One generated parameter: a label, a readout, and the track above.
//
// The readout is *not* editable, where the settings page's `SliderRow` pairs
// the same track with a number box. That is a considered difference rather than
// an inconsistency: this column is 280px wide and the colour mixer alone puts
// thirty-six of these in it, so a text box per row would be most of the width
// and a keyboard target nobody is aiming for. The number is still reachable —
// the track resets on double-click and right-click.
component ParamSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
@@ -336,27 +193,13 @@ component ParamSlider inherits Rectangle {
height: 46px;
VerticalLayout {
spacing: 2px;
HorizontalLayout {
Label {
text: root.data.param-label;
emphasised: root.data.value != root.data.default-value;
}
Rectangle { horizontal-stretch: 1; }
Value {
text: Readout.of(root.data);
// The one readout in the panel that has moved off its default
// is what the eye is hunting for, and `modified` is the only
// thing left to say it with once hue is gone.
modified: root.data.value != root.data.default-value;
placeholder: root.data.value == root.data.default-value;
compact: true;
}
}
ControlRow {
label: root.data.param-label;
readout: Readout.of(root.data);
// The one readout in the panel that has moved off its default is what
// the eye is hunting for, and `modified` is the only thing left to say
// it with once hue is gone.
modified: root.data.value != root.data.default-value;
SliderTrack {
value: root.data.value;
@@ -486,24 +329,10 @@ component PlainSlider inherits Rectangle {
height: 46px;
VerticalLayout {
spacing: 2px;
HorizontalLayout {
Label {
text: root.label;
emphasised: root.value != root.default-value;
}
Rectangle { horizontal-stretch: 1; }
Value {
text: (Math.round(root.value * 10) / 10) + root.unit;
modified: root.value != root.default-value;
placeholder: root.value == root.default-value;
compact: true;
}
}
ControlRow {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
modified: root.value != root.default-value;
SliderTrack {
value: root.value;
@@ -518,6 +347,97 @@ component PlainSlider inherits Rectangle {
}
}
// **The control registry: one row in, one control out.**
//
// Slint cannot instantiate a component from a runtime string, so mapping a
// declared kind to a control is necessarily a chain of `if`s. The thing worth
// insisting on is that there is exactly *one* such chain. There were two — the
// panel draws a lone parameter bare and a group under a heading, and each
// branch wrote out its own list of kinds — so `enum` would have had to be added
// in both, and a kind added to only one would appear or vanish depending on how
// many parameters its operation happened to declare.
//
// Everything below routes back through `param-changed` by index. This component
// knows a curve point spans two parameters and a swatch names a hue band; it
// knows nothing about which operation it is drawing, which is the property that
// makes it a registry rather than a panel.
component ParamControl inherits Rectangle {
in property <ParamRow> data;
/// Curve rows only; ignored by every other kind.
in property <[float]> curve-samples;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback drag-changed(bool);
height: layout.preferred-height;
layout := VerticalLayout {
spacing: 0px;
alignment: start;
// A row whose subject is a colour is identified by that colour; every
// other scalar keeps its name. The two differ only in what stands in
// for the label — the track, the gestures and the routing are the same
// underneath.
if root.data.kind == "scalar" && root.data.swatch-hue >= 0: SwatchSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
if root.data.kind == "scalar" && root.data.swatch-hue < 0: ParamSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
// A fixed list of alternatives. The value *is* the index, so picking
// one is an ordinary parameter change and needs no separate route.
//
// Chips rather than a dropdown for the same reason the settings page
// uses them: these lists are short, and a collapsed menu hides the
// alternatives behind a click. ARCH §4.3 names the dropdown as the
// pointer presentation of the same kind, so this is where that choice
// will be made when the modality switch lands.
if root.data.kind == "enum": Segmented {
label: root.data.param-label;
options: root.data.choices;
selected: Math.round(root.data.value);
picked(i) => {
root.param-changed(root.data.op-index, root.data.param-index, i);
}
}
if root.data.kind == "curve": CurveEditor {
points: root.data.points;
samples: root.curve-samples;
drag-changed(on) => { root.drag-changed(on); }
// A point carries two parameters, so the parameter index is the
// row's base plus the point's offset. This component still knows
// nothing about which operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
root.data.op-index, root.data.param-index + point * 2, x);
root.param-changed(
root.data.op-index, root.data.param-index + point * 2 + 1, y);
}
reset => { root.curve-reset(root.data.op-index); }
}
}
}
// Crop, rotation, flips and straightening — the framing controls.
//
// **Why this is hand-built when the rest of the panel is generated.** The
@@ -632,165 +552,6 @@ export component GeometryPanel inherits Rectangle {
}
}
// A tone curve editor: a square grid with draggable control points.
//
// The curve *line* is drawn from `samples`, which Rust evaluates with the
// same spline the shader uses. Reimplementing the interpolation here would
// mean two curves that could disagree — the drawn one and the applied one —
// which is the worst possible failure for a control whose whole job is to
// show you what it is doing.
component CurveEditor inherits Rectangle {
in property <ParamRow> data;
// Polyline of the curve, y values sampled at even x. 0..1, y up.
in property <[float]> samples;
// Which point is being dragged, or -1.
in-out property <int> active-point: -1;
callback point-moved(int, float, float);
callback reset();
/// The pointer is on a control point. The panel stands its Flickable down
/// while it is, for the reason spelled out on `ParamSlider`'s `engaged` —
/// and more acutely here, because a curve point is dragged *vertically*,
/// which is the Flickable's own axis and so is contested every time.
callback drag-changed(bool);
property <int> point-count: root.data.points.length / 2;
// Hover, not the drag: `active-point` is set on press, and the press is
// exactly what a Flickable withholds. Only the plot's grab targets count,
// so the rest of the plot still scrolls the panel.
property <bool> engaged: root.active-point >= 0 || root.hovered-point >= 0;
changed engaged => { root.drag-changed(root.engaged); }
// The point the pointer is over, or -1. Set by the grab targets below,
// and used only to highlight the marker.
in-out property <int> hovered-point: -1;
// Square: a tone curve is read as a deviation from the 45° diagonal, and
// that reading only works if the axes share a scale.
height: self.width;
plot := Rectangle {
background: Theme.ground;
border-width: 1px;
border-color: Theme.rule;
// Quarter gridlines and the identity diagonal, so the shape of the
// edit is legible at a glance.
for i in [1, 2, 3]: Rectangle {
x: parent.width * i / 4;
width: 1px;
background: Theme.rule;
opacity: 0.5;
}
for i in [1, 2, 3]: Rectangle {
y: parent.height * i / 4;
height: 1px;
background: Theme.rule;
opacity: 0.5;
}
// The curve. One thin rectangle per sample: Slint has no polyline
// primitive, and at this size the segments are sub-pixel anyway.
for s[i] in root.samples: Rectangle {
property <float> next: i + 1 < root.samples.length
? root.samples[i + 1] : s;
x: parent.width * i / max(root.samples.length - 1, 1);
width: parent.width / max(root.samples.length - 1, 1) + 1px;
// Span the segment vertically, so a steep section stays joined.
y: parent.height * (1.0 - max(s, self.next));
height: max(parent.height * abs(self.next - s), 1.5px);
background: Theme.active;
}
// Control points.
for idx in [0, 1, 2, 3, 4]: Rectangle {
property <bool> exists: idx < root.point-count;
property <float> px: root.data.points[idx * 2];
property <float> py: root.data.points[idx * 2 + 1];
visible: self.exists;
x: parent.width * self.px - 5px;
y: parent.height * (1.0 - self.py) - 5px;
width: 10px;
height: 10px;
border-radius: 5px;
// Grown and tinted when grabbable, so it is obvious where the
// curve takes the gesture and where the panel scrolls instead.
property <bool> live: root.active-point == idx
|| root.hovered-point == idx;
background: self.live ? Theme.active : Theme.ink;
border-width: 1px;
border-color: Theme.ground;
}
// **One grab target per point, and nothing covering the rest.**
//
// Three constraints meet here, and only this arrangement satisfies
// all of them:
//
// 1. The panel scrolls, and Slint cannot hand back a press once
// taken — so an area spanning the plot would swallow every scroll
// gesture beginning over the curve. Small targets leave the rest
// of the plot free.
// 2. `enabled: false` does not work as a gate: a disabled TouchArea
// recognises *no* events at all, hover included, so it cannot
// report where the pointer is in order to decide.
// 3. A target positioned by its own point would slide out from under
// the pointer on the first movement, stalling the drag. So while
// a point is being dragged its target **freezes** at the press
// position and grows to cover the plot, keeping the pointer
// inside it however far the point travels.
for idx in [0, 1, 2, 3, 4]: TouchArea {
property <bool> exists: idx < root.point-count;
property <bool> dragging: root.active-point == idx;
// Frozen and expanded while dragging; tracking the point
// otherwise.
x: self.dragging ? 0px
: parent.width * root.data.points[idx * 2] - 14px;
y: self.dragging ? 0px
: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 14px;
width: self.dragging ? parent.width : 28px;
height: self.dragging ? parent.height : 28px;
visible: self.exists;
mouse-cursor: pointer;
// Highlights the marker, so it is visible where the curve takes
// the gesture and where the panel scrolls instead.
changed has-hover => {
if (self.has-hover) {
root.hovered-point = idx;
} else if (root.hovered-point == idx) {
root.hovered-point = -1;
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.left) {
root.active-point = idx;
}
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
root.active-point = -1;
}
}
moved => {
if (self.dragging) {
// Coordinates are relative to this area, which is the
// whole plot while dragging — so no offset is needed.
root.point-moved(
idx,
clamp(self.mouse-x / parent.width, 0.0, 1.0),
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
}
}
double-clicked => { root.reset(); }
}
}
}
// The panel: a heading per multi-parameter operation, a control per parameter.
//
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
@@ -906,33 +667,15 @@ export component AdjustPanel inherits Rectangle {
//
// `row` is the entry here — the group's head is its only
// member — so there is nothing to index back into.
if row.group-head == i && row.group-len == 1: VerticalLayout {
spacing: 0px;
if row.kind == "scalar": ParamSlider {
data: row;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
root.param-changed(
row.op-index, row.param-index, v);
}
reset => {
root.param-reset(row.op-index, row.param-index);
}
}
if row.kind == "curve": CurveEditor {
data: row;
samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
point-moved(point, x, y) => {
root.param-changed(
row.op-index, row.param-index + point * 2, x);
root.param-changed(
row.op-index, row.param-index + point * 2 + 1, y);
}
reset => { root.curve-reset(row.op-index); }
if row.group-head == i && row.group-len == 1: ParamControl {
data: row;
curve-samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
param-changed(op, param, v) => {
root.param-changed(op, param, v);
}
param-reset(op, param) => { root.param-reset(op, param); }
curve-reset(op) => { root.curve-reset(op); }
}
// `if` rather than a zero height: a hidden-but-present
@@ -975,52 +718,17 @@ export component AdjustPanel inherits Rectangle {
title: entry.facet-label;
}
// A row whose subject is a colour is identified by
// that colour; every other scalar keeps its name.
// The two differ only in what stands in for the
// label — the track, the gestures and the routing
// are the same underneath.
if entry.kind == "scalar" && entry.swatch-hue >= 0: SwatchSlider {
ParamControl {
data: entry;
curve-samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
root.param-changed(
entry.op-index, entry.param-index, v);
param-changed(op, param, v) => {
root.param-changed(op, param, v);
}
reset => {
root.param-reset(entry.op-index, entry.param-index);
}
}
if entry.kind == "scalar" && entry.swatch-hue < 0: ParamSlider {
data: entry;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
root.param-changed(
entry.op-index, entry.param-index, v);
}
reset => {
root.param-reset(entry.op-index, entry.param-index);
}
}
if entry.kind == "curve": CurveEditor {
data: entry;
samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
// A point carries two parameters, so the
// parameter index is the row's base plus the
// point's offset. This component still knows
// nothing about which operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
entry.op-index, entry.param-index + point * 2, x);
root.param-changed(
entry.op-index, entry.param-index + point * 2 + 1, y);
}
reset => {
root.curve-reset(entry.op-index);
param-reset(op, param) => {
root.param-reset(op, param);
}
curve-reset(op) => { root.curve-reset(op); }
}
}
}