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
+35 -242
View File
@@ -1,5 +1,6 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, ProgressBar, ActivityRow, Icon } from "widgets.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar, ActivityRow } from "widgets.slint";
import { Segmented, TextRow, Check, SliderRow } from "controls.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
//
@@ -15,227 +16,6 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, Field, ProgressBar,
// switches views this way — launch, library, develop — and an overlay would be
// a fourth mechanism for the same job.
// One choice out of several, drawn as a row of chips.
//
// Not a dropdown. Every choice set on this page is short and the options are
// worth reading side by side — a photographer picking an output colour space
// benefits from seeing that ProPhoto exists next to sRGB, which a collapsed
// menu hides behind a click. `FilterChip` in widgets.slint is the same idea for
// the library's rating filter, but it carries a count and a filter's
// on-off semantics; this is single-selection over a fixed list, so the
// behaviour differs where it matters.
component ChoiceChip inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> enabled: true;
callback clicked();
height: Theme.control-height;
// Wide enough that a one-word label is still a comfortable target, which
// is what `control-min-width` exists for — but chips sit several to a row,
// so they take their own narrower floor rather than the button's.
min-width: 64px;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.selected ? Theme.active : Theme.rule;
// The selected chip fills, matching the checked box and the slider fill:
// `active` is the token for an engaged control, and this is the one chip in
// the row that is engaged.
background: !root.enabled ? transparent
: (root.selected ? Theme.active
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : transparent)));
opacity: root.enabled ? 1.0 : 0.4;
touch := TouchArea {
// FR-UI-3: the drawn chip is `control-height`, so the target grows
// past its own bounds rather than the ink growing.
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
text: root.label;
// Dark on the fill: `active` is near-white and ink on it is invisible.
color: root.selected ? Theme.ground : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// A labelled row of chips, with the label above rather than beside.
//
// Above, because the chip rows are wide and a left-hand label column would
// either crush them or leave the page half empty at narrow widths. Stacked,
// every row uses the full width at any window size (FR-UI-1).
component ChoiceRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <[string]> options;
in property <int> selected: 0;
in property <bool> enabled: true;
callback picked(int);
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
for option[i] in root.options: ChoiceChip {
label: option;
selected: i == root.selected;
enabled: root.enabled;
clicked => { root.picked(i); }
}
}
}
// A switch: one setting that is either on or off.
//
// The tick-box shape is `FormatCheck`'s from launch.slint, which is the
// established idiom for a boolean in this codebase. Reproduced rather than
// shared because that one is private to the launch screen and lives inside its
// format list; lifting it into widgets.slint would be the better move once a
// third caller appears, and doing it for the second is how a component ends up
// with parameters for every caller's variation.
component Switch inherits Rectangle {
in property <string> label;
in property <string> hint;
in-out property <bool> checked;
callback toggled(bool);
height: max(row.preferred-height, Theme.control-height);
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
row := HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Icon {
name: "check";
ink: Theme.ground;
size: 11px;
visible: root.checked;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
VerticalLayout {
spacing: 1px;
alignment: center;
Label { text: root.label; body: true; emphasised: touch.has-hover; }
// The hint carries *why* a default is what it is, for the settings
// where that is not obvious from the name — upscaling being off,
// location being stripped. A page of bare switches makes the user
// guess at the consequence of each.
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
}
}
}
// A text entry with its label above and an optional unit after it.
//
// `Field` is `touch-target` tall and stretches, which is right for a server
// URL on the launch screen and wrong for a byte count — so this constrains the
// width rather than restyling the field.
component EntryRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in-out property <string> text;
in property <string> unit;
in property <string> placeholder;
in property <bool> enabled: true;
in property <length> field-width: 140px;
callback accepted(string);
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
Rectangle {
width: root.field-width;
height: field.preferred-height;
opacity: root.enabled ? 1.0 : 0.4;
field := Field {
width: 100%;
text <=> root.text;
placeholder: root.placeholder;
// Committed on Enter *and* on losing focus. Enter alone loses
// an edit the moment the user clicks the next control, which
// on a page that saves continuously reads as the setting not
// having taken.
accepted(t) => { root.accepted(t); }
}
// `Field` reports focus but does not signal losing it, so the
// change is watched here.
property <bool> focused: field.has-focus;
changed focused => {
if (!self.focused) {
root.accepted(root.text);
}
}
}
Label {
text: root.unit;
visible: root.unit != "";
vertical-alignment: center;
height: Theme.touch-target;
}
}
}
// One background job: what it is, how far along, and what it last said.
//
// A row rather than a `Value`/`Caption` pair written out at the call site,
@@ -533,7 +313,7 @@ export component SettingsPage inherits Rectangle {
Rectangle { height: Theme.gap-sm; }
EntryRow {
TextRow {
label: "Cached originals";
hint: "evicted oldest-first when full";
text <=> root.original-budget;
@@ -542,7 +322,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.original-budget-changed(t); }
}
Switch {
Check {
label: "No limit on cached originals";
hint: "Nothing is ever evicted for space. "
+ "Pinned photographs are kept regardless.";
@@ -552,7 +332,7 @@ export component SettingsPage inherits Rectangle {
Rectangle { height: Theme.gap-sm; }
EntryRow {
TextRow {
label: "Thumbnails and previews";
hint: "what the grid draws from";
text <=> root.thumbnail-budget;
@@ -561,7 +341,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.thumbnail-budget-changed(t); }
}
Switch {
Check {
label: "No limit on thumbnails";
checked: root.thumbnail-unlimited;
toggled(on) => { root.thumbnail-unlimited-toggled(on); }
@@ -569,7 +349,7 @@ export component SettingsPage inherits Rectangle {
Rectangle { height: Theme.gap-sm; }
Switch {
Check {
label: "Keep originals after opening them";
hint: "The file was downloaded anyway, so keeping it "
+ "costs no bandwidth and saves the transfer next time.";
@@ -596,26 +376,39 @@ export component SettingsPage inherits Rectangle {
wrap: word-wrap;
}
ChoiceRow {
Segmented {
label: "Format";
options: root.format-labels;
selected: root.format-selected;
picked(i) => { root.format-picked(i); }
}
EntryRow {
// A bounded number, so it gets the control for one.
//
// This was a free-text field: the range lived in the
// hint and was enforced nowhere, and `to-float()`
// answers 0 for anything unparseable — so a typo saved
// a quality of 0 and the page then showed the 0 back as
// though it had been asked for. The track carries the
// range and the box refuses what it cannot read.
SliderRow {
label: "Quality";
// Says why it is greyed rather than leaving the
// user to work out that PNG has no quality.
hint: root.quality-enabled ? "1 to 100"
: "the chosen format is lossless";
text: root.quality;
value: root.quality;
// No meaningful neutral: quality has a sensible
// default but not a *zero*, and a default marker
// partway along a track reads as one.
default-value: 1;
minimum: 1;
maximum: 100;
enabled: root.quality-enabled;
field-width: 90px;
accepted(t) => { root.quality-changed(t.to-float()); }
changed(v) => { root.quality-changed(v); }
}
ChoiceRow {
Segmented {
label: "Colour space";
hint: "profile embedded on export";
options: root.colour-labels;
@@ -623,7 +416,7 @@ export component SettingsPage inherits Rectangle {
picked(i) => { root.colour-picked(i); }
}
ChoiceRow {
Segmented {
label: "Size";
options: root.sizing-labels;
selected: root.sizing-selected;
@@ -633,7 +426,7 @@ export component SettingsPage inherits Rectangle {
// Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels".
if root.sizing-has-value: EntryRow {
if root.sizing-has-value: TextRow {
label: "Size value";
text: root.sizing-value;
unit: root.sizing-unit;
@@ -641,7 +434,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.sizing-value-changed(t); }
}
Switch {
Check {
label: "Allow upscaling";
hint: "Off, a request larger than the source exports "
+ "at source size rather than failing.";
@@ -649,7 +442,7 @@ export component SettingsPage inherits Rectangle {
toggled(on) => { root.upscaling-toggled(on); }
}
ChoiceRow {
Segmented {
label: "Output sharpening";
hint: "scaled by the resize factor";
options: root.sharpening-labels;
@@ -674,7 +467,7 @@ export component SettingsPage inherits Rectangle {
PanelHeading { text: "FILES AND METADATA"; }
EntryRow {
TextRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> root.filename-template;
@@ -683,7 +476,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.template-changed(t); }
}
ChoiceRow {
Segmented {
label: "If the file exists";
options: root.collision-labels;
selected: root.collision-selected;
@@ -695,14 +488,14 @@ export component SettingsPage inherits Rectangle {
// the Storage Access Framework at all, and on any
// platform a server destination is reached over a
// network that may not be there.
ChoiceRow {
Segmented {
label: "Export to";
options: root.target-labels;
selected: root.target-selected;
picked(i) => { root.target-picked(i); }
}
EntryRow {
TextRow {
label: "Destination";
hint: "empty asks each time";
text <=> root.destination;
@@ -711,7 +504,7 @@ export component SettingsPage inherits Rectangle {
accepted(t) => { root.destination-changed(t); }
}
Switch {
Check {
label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a "