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:
+35
-242
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user