Files
DarkRoom/ui/dr-ui/ui/controls.slint
T
dtourolleandClaude Opus 5 8e4befa727 Say what each control is, so a screen reader can use one
The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:32 +02:00

1075 lines
46 KiB
Plaintext

// The control vocabulary: everything that takes input.
//
// **Why this is a second file rather than more of widgets.slint.** That file
// establishes the rule — screens consume components, and a bare `Theme.*` at a
// call site means a component is missing — and it establishes it for *chrome*:
// buttons, panels, headings, the text roles. Chrome is drawn and read. What is
// below is dragged, typed into and toggled, which is a different kind of thing
// with a different set of concerns: gesture arbitration, validation, hit
// targets, and what a control does when the value moves under it. Keeping them
// apart is what lets either file stay readable.
//
// **The rule this file establishes.** A control's *behaviour* is written once,
// here, and its numbers come from the caller. Before this file the slider lived
// inside the develop panel and was reachable from nowhere else, so the settings
// page — which has a 1-to-100 quality and three other bounded numbers — offered
// a free-text box instead, and `to-float()` silently turned a typo into zero. A
// primitive that only one screen can reach is not a primitive.
//
// **Nothing here knows about `ParamRow`.** That struct is the develop panel's
// flattening of the capability model and lives in adjust.slint; a control that
// imported it would drag the whole develop model into the settings page. The
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers
// stay in the panel that owns the model. This is the constraint that makes the
// file reusable, so it is worth stating rather than merely observing.
//
// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see
// widgets.slint's preamble for the two Slint constraints that shape it): a
// control's role, and the actions assistive technology can invoke on it, are
// written once here. Its *name* is the one thing that cannot be — a track has
// no idea what number it is dragging — so every primitive below takes a
// `label`, and the wrappers that do know pass it down. An unnamed control is
// the failure mode that matters: a screen reader announcing "slider, 0.35" for
// each of the thirty-six controls in the colour mixer has told the user
// nothing at all.
import { Theme } from "theme.slint";
import { Icon, Label, Value, Caption, Field } from "widgets.slint";
// --- the slider ---------------------------------------------------------
// **The** slider track. 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 now differ only in where
// their numbers come from.
//
// It moved here from adjust.slint unchanged. The argument above is the reason
// the move matters: a track private to one panel is a track the next screen
// reimplements, which is the same failure one level up.
export component SliderTrack inherits Rectangle {
in property <float> value;
in property <float> default-value;
in property <float> minimum;
in property <float> maximum;
/// What this track adjusts. The track's accessible name.
///
/// Every wrapper already draws this word somewhere — `ControlRow` puts it
/// above, `FieldRow` puts it above and to the left — and none of those
/// placements associates it with the control as far as the platform is
/// concerned. `SwatchSlider` is the case that makes the point: it draws no
/// word at all, only a coloured square, and its name has *always* had to
/// travel this way.
in property <string> label;
/// The value as the user should hear it, already formatted.
///
/// Empty falls back to the raw number, which is right for a track whose
/// caller has nothing better; a caller with a declared precision and a
/// unit hands over what it is drawing, so "+1.25 EV" is announced rather
/// than "1.2500000298".
///
/// A string rather than a float for the reason `ControlRow.readout` gives:
/// precision belongs to whoever owns the value, and a control that rounded
/// on its own would announce a parameter one way and draw it another.
in property <string> readout;
/// How far one assistive-technology nudge moves the value. Zero takes a
/// hundredth of the range, which is the resolution a drag has anyway.
in property <float> step: 0;
/// Live, once per movement. For anything that should follow the drag: a
/// readout, a preview, the image itself.
callback changed(float);
/// The gesture is over and this is the value to keep.
///
/// **Two callbacks because there are two costs.** Re-rendering a
/// photograph on every movement is the entire point of a develop slider,
/// and the pipeline is built for it. Writing a settings file on every
/// movement is not: a two-second drag is a couple of hundred serialise-
/// and-save round trips where a text field committed once, and on Android
/// that goes through the Storage Access Framework. So a caller whose
/// handler is cheap listens to `changed`, and one whose handler is
/// expensive listens to this — rather than every such caller inventing its
/// own debounce, which is how two of them come to disagree about when an
/// edit is finished.
callback committed(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);
property <float> nudge: root.step > 0 ? root.step : root.span / 100;
// **One nudge is a whole gesture, so it commits.**
//
// The two callbacks exist because a drag is many movements and one
// decision (see `committed` above). An arrow key pressed once is both at
// the same time: there is no stream to debounce and no release to wait
// for, so a nudge that only fired `changed` would move the photograph and
// never be saved by any caller that listens for the end of a drag — which
// is every settings-shaped caller in the application.
function move-to(v: float) {
root.changed(clamp(v, root.minimum, root.maximum));
root.committed(clamp(v, root.minimum, root.maximum));
}
// **The only route to this control that is not a pointer.** ui-navigation
// D-N2 rules out hover as the sole affordance; a control reachable only by
// dragging it is the same objection with the pointer itself as the
// modifier. These three actions are what AT-SPI and TalkBack drive a
// slider with, and they are what makes the track adjustable rather than
// merely readable.
accessible-role: slider;
accessible-label: root.label;
// Both arms of the ternary must be strings — the empty concatenation is
// what makes the fallback one.
accessible-value: root.readout != "" ? root.readout : (root.value + "");
accessible-value-minimum: root.minimum;
accessible-value-maximum: root.maximum;
accessible-value-step: root.nudge;
accessible-action-increment => { root.move-to(root.value + root.nudge); }
accessible-action-decrement => { root.move-to(root.value - root.nudge); }
accessible-action-set-value(v) => {
// `is-float()` for the reason `NumberField` gives at length:
// `to-float()` answers 0 for a string it could not parse, and a
// screen reader handing over "abc" would silently set the exposure to
// zero rather than reject the entry.
if (v.is-float()) {
root.move-to(v.to-float());
}
}
// **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;
// The last value this gesture emitted, held until it is committed.
//
// Kept here rather than read back from `root.value` at release: the
// parent may or may not have fed the live value back down, and a
// commit that reported the *old* number on a caller who ignored
// `changed` would silently save the wrong thing.
property <float> pending-value;
property <bool> pending: false;
function value-at(px: length) -> float {
return clamp(
root.minimum + (px / self.width) * root.span,
root.minimum,
root.maximum);
}
function emit(v: float) {
self.pending-value = v;
self.pending = true;
root.changed(v);
}
// Idempotent, because both the pointer-up and the `clicked` that
// follows it are legitimate places to notice a gesture has ended and
// only one of them should commit.
function commit() {
if (self.pending) {
self.pending = false;
root.committed(self.pending-value);
}
}
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) {
self.emit(self.value-at(self.mouse-x));
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
self.claimed = false;
// A drag ending outside the track never produces `clicked`, so
// the commit cannot wait for one.
self.commit();
}
// 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) {
self.emit(self.value-at(self.mouse-x));
}
self.commit();
}
double-clicked => { root.reset(); }
}
}
// --- numeric entry ------------------------------------------------------
// A number typed rather than dragged, held to a declared range and precision.
//
// **The parsing is the point.** `Field` plus `to-float()` — which is what the
// settings page did before this existed — cannot tell a rejected entry from a
// deliberate zero, because `to-float()` answers 0 for both. So a mistyped
// export quality silently became 0, was saved, and the page then displayed the
// 0 as though the user had asked for it. `is-float()` is the missing question,
// and asking it is the whole reason this is a component and not a `Field` with
// a call-site handler.
//
// **A rejected entry reverts rather than erroring.** There is nowhere to put a
// validation message on a settings row that would not push every control below
// it down the page, and a control that rejects input by snapping back to the
// last good value has already said what happened. Out-of-range is different
// from unparseable and is *not* rejected: 500 in a 1-to-100 field is a clear
// intention, so it clamps to 100 and shows the 100, which is both what the
// pipeline received and an answer to "why did that not take".
export component NumberField inherits Rectangle {
in property <float> value;
in property <float> minimum;
in property <float> maximum;
/// What the number means. Handed straight to the entry, which is where the
/// accessibility tree wants it — see `Field.label`.
in property <string> label;
/// Decimal places shown, and the precision an entry is held to. Zero for a
/// count, two for a value in stops — the same figure a parameter
/// descriptor declares.
in property <int> precision: 0;
in property <bool> enabled: true;
callback changed(float);
width: 72px;
height: Theme.touch-target;
horizontal-stretch: 0;
opacity: root.enabled ? 1.0 : 0.4;
property <float> factor: Math.pow(10, root.precision);
pure function shown(v: float) -> string {
return Math.round(v * root.factor) / root.factor + "";
}
// The text in the box, deliberately *not* a binding on `value`.
//
// A binding would be broken permanently the first time the TextInput wrote
// through it — Slint drops a binding on assignment — so the box would
// follow the value until the first keystroke and never again. Seeded on
// `init` and rewritten from the two events where rewriting is correct:
// when the value moves under the box (a slider drag, a reset elsewhere),
// and when an entry is committed.
in-out property <string> text;
init => { root.text = root.shown(root.value); }
changed value => { root.text = root.shown(root.value); }
function commit() {
if (root.text.is-float()) {
root.changed(clamp(root.text.to-float(), root.minimum, root.maximum));
}
// Rewritten either way. A valid entry is echoed back clamped and at the
// declared precision, so the box always shows the number the pipeline
// actually holds; an invalid one is discarded and the last good value
// returns.
root.text = root.shown(root.value);
}
field := Field {
width: 100%;
height: 100%;
label: root.label;
text <=> root.text;
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter
// alone loses the edit the moment the user clicks the next control,
// which on a page that saves continuously reads as the setting not
// having taken.
accepted => { root.commit(); }
}
property <bool> focused: field.has-focus;
changed focused => {
if (!self.focused) {
root.commit();
}
}
}
// --- booleans -----------------------------------------------------------
// A tick-box and its label: one setting that is either on or off.
//
// Written twice before this — `FormatCheck` in launch.slint and `Switch` in
// settings.slint — from the same 18px box with the same `active` fill and the
// same toggle handler. The second copy carried a comment saying the lift
// belonged here "once a third caller appears", which was the right instinct
// and the wrong threshold: the two copies had already drifted in height, and
// the third caller is this file establishing what a boolean looks like.
//
// **A ticked box fills rather than merely outlining.** `active` is the token
// for an engaged control, and it is what the slider fill and the focus border
// take, so a checked box reads as the same kind of state as those.
export component Check inherits Rectangle {
in property <string> label;
/// Why the default is what it is, for settings where the consequence is
/// not obvious from the name — upscaling being off, location being
/// stripped. Empty on a box whose label says it already; a page of bare
/// switches makes the user guess at what each one costs.
in property <string> hint;
in-out property <bool> checked;
callback toggled(bool);
// The hint becomes the description rather than part of the name. It exists
// to say what a setting *costs* — "location is stripped", "upscaling is
// off" — which is the second thing a reader wants and never the first, and
// a name that carried it would read the whole sentence back on every pass
// through the page.
accessible-role: checkbox;
accessible-label: root.label;
accessible-description: root.hint;
accessible-checkable: true;
accessible-checked: root.checked;
accessible-action-default => {
root.checked = !root.checked;
root.toggled(root.checked);
}
height: max(row.preferred-height, Theme.control-height);
touch := TouchArea {
// FR-UI-3: the drawn row is shorter than a touch target, 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;
mouse-cursor: pointer;
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";
// Dark on the fill: `active` is near-white, and the white tick
// this carried against a saturated accent is invisible on it.
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; }
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
}
}
}
// --- one choice out of several ------------------------------------------
// One term in a `Segmented`.
//
// Not `FilterChip`, which widgets.slint keeps for the library's rating filter:
// that one carries a count and has on-off semantics, where this is
// single-selection over a fixed list. They look alike and behave differently,
// which is exactly the case for two components rather than one with a flag.
export component ChoiceChip inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> enabled: true;
callback clicked();
// `radio-button` and not `button`, because single-selection over a fixed
// list is what a radio button *is* — and the difference is audible: a
// reader announcing "radio button, selected" has told the user that
// picking another one will unpick this, which "button, pressed" has not.
// It is the same distinction the prose above draws against `FilterChip`,
// said in the vocabulary the platform already has a word for.
//
// **No `radio-group` around them.** The role exists, and the natural home
// for it — `Segmented` — is a `VerticalLayout` rather than the plain root
// Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to
// `ChipGrid` for a wrapped set, so the group would either sit on a layout
// element or be declared twice and nest. The group's name still reaches a
// reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own,
// immediately before the chips in traversal order.
accessible-role: radio-button;
accessible-label: root.label;
accessible-enabled: root.enabled;
accessible-checkable: true;
accessible-checked: root.selected;
accessible-action-default => {
if (root.enabled) {
root.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;
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 {
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%;
}
}
// --- the labelled-row scaffold ------------------------------------------
// A control's name, with an aside about it on the same line.
//
// Written out three times inside settings.slint before this — once each in the
// choice row, the entry row and the switch — as a body `Label` beside a
// right-aligned elided `Caption`. Three copies of a two-element layout is how
// the hint ends up aligned differently from one row to the next.
//
// The name sits *above* its control rather than beside it: the control 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).
export component FieldRow inherits HorizontalLayout {
in property <string> label;
in property <string> hint;
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
// TRACES: FR-UI-2
// A block of chips that wraps, laid out by arithmetic rather than by a layout.
//
// **Slint has no wrapping layout, and a `HorizontalLayout` of chips is a
// hazard in a narrow column.** Chips carry a comfortable minimum width, so a
// row of them reports that minimum times their count as the width it needs —
// and in the develop column, where every panel's declared width is `max`ed
// into one number, a single six-choice parameter silently sets the width of
// the whole application's sidebar. `film_sim`'s six film formats did exactly
// that: 414px of chips holding a column open that documents itself, in a
// dozen comments, as 280px wide.
//
// A `GridLayout` is not the alternative — a `for` inside one compiles and then
// fails at run time with `RepeatedItemTree::grid_layout_input_data() not
// implemented`, piling every chip into a single row. So the grid is index
// arithmetic inside a plain `Rectangle`: it costs the layout engine nothing,
// it wraps a seventh choice onto a third row by itself, and it declares a
// width that depends on the column count rather than on the choice count.
//
// **The chip width is fixed, not a share of the container's.** Dividing
// `self.width` looks right and clips: the develop column sets its width to
// `min(panel-max-width, panel-width)` with `clip: true`, so when the policy
// bites, `self.width` here is the width that was *asked for* and the visible
// column is narrower.
export component ChipGrid inherits Rectangle {
in property <[string]> options;
in property <int> selected: -1;
in property <bool> enabled: true;
/// How many chips to a row. Three is what fits the narrowest column the
/// application supports.
in property <int> columns: 3;
/// Comfortable for a one-word label at `Theme.text`, and three of them
/// plus their gaps fit a 280px column.
in property <length> chip-width: 88px;
callback picked(int);
property <int> rows: max(1, ceil(root.options.length / max(1, root.columns)));
property <length> pitch: Theme.control-height + Theme.gap-sm;
// Stated so a column measuring itself counts this block, and counts it at
// the width it will actually draw at rather than at the width a single row
// of every choice would need.
min-width: root.columns * root.chip-width + (root.columns - 1) * Theme.gap-sm;
height: root.rows * root.pitch - Theme.gap-sm;
for option[i] in root.options: ChoiceChip {
x: mod(i, root.columns) * (root.chip-width + Theme.gap-sm);
y: floor(i / root.columns) * root.pitch;
width: root.chip-width;
height: Theme.control-height;
label: option;
selected: i == root.selected;
enabled: root.enabled;
clicked => { root.picked(i); }
}
}
// A labelled row of chips: one choice out of a short list.
//
// Not a dropdown. Every choice set on the settings 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. ARCH §4.3 names the dropdown as the
// *pointer* presentation of the same `Enum`; this is the segmented one, and
// when the modality switch lands it will sit beside a dropdown rather than be
// replaced by one.
export component Segmented inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <[string]> options;
in property <int> selected: 0;
in property <bool> enabled: true;
/// Wrap onto this many chips per row instead of laying them all in one.
///
/// Zero — one row, however many chips — is right for the settings page,
/// which is a full-width page and where reading the alternatives side by
/// side is the whole argument for chips over a dropdown. The develop
/// column is the opposite case: it is narrow, its width is the largest any
/// panel asks for, and a row of six chips there sets the width of the
/// sidebar for every other panel. See `ChipGrid`.
in property <int> columns: 0;
callback picked(int);
spacing: 4px;
FieldRow { label: root.label; hint: root.hint; }
if root.columns <= 0: 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); }
}
}
if root.columns > 0: ChipGrid {
options: root.options;
selected: root.selected;
enabled: root.enabled;
columns: root.columns;
picked(i) => { root.picked(i); }
}
}
// 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 filename template — so this constrains
// the width rather than restyling the field.
export component TextRow 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;
FieldRow { label: root.label; hint: root.hint; }
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%;
label: root.label;
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;
}
}
}
// --- sliders that carry their own readout -------------------------------
// A control with its name and current value on the line above it.
//
// The develop panel's shape, generalised: a parameter's name on the left, what
// it currently reads on the right, and the control itself beneath. The control
// arrives as `@children` rather than being built here, so the same header
// serves a track today and whatever else wants one later without this
// component learning what a track is.
//
// **`modified` drives both halves.** A value differing from its default is the
// single thing a photographer scans a panel for, and with hue gone from the
// palette the only signal left is luminance — so the label lights and the
// readout changes ink together, from one fact, rather than two call sites each
// deciding.
export component ControlRow inherits VerticalLayout {
in property <string> label;
/// Already formatted. Precision belongs to whoever owns the value — a
/// descriptor declares it — and a control that rounded on its own would
/// show the same parameter two ways in the same panel.
in property <string> readout;
in property <bool> modified: false;
spacing: 2px;
HorizontalLayout {
Label { text: root.label; emphasised: root.modified; }
Rectangle { horizontal-stretch: 1; }
Value {
text: root.readout;
modified: root.modified;
placeholder: !root.modified;
compact: true;
}
}
@children
}
// A slider and an editable number box for the same value.
//
// **The pointer presentation of a bounded scalar** (ARCH §4.3): the track for
// choosing a value by eye, the box for saying one exactly. Neither alone is
// enough — a track cannot express "exactly 90", and a bare number box makes
// the user guess what the range is until they exceed it.
//
// This is what the settings page's bounded numbers should have been. Export
// quality is 1-to-100 and was a free-text field, so the range was written in a
// hint and enforced nowhere, and `to-float()` turned a typo into zero.
//
// Distinct from `ControlRow` + `SliderTrack`, which is what the develop panel
// uses: there the readout is *not* editable, because that column is 280px wide
// and holds thirty-six of these in the colour mixer alone — a text box per row
// would be most of the width and a keyboard target nobody is aiming for. Two
// presentations of one idea, and which is right depends on how many are on
// screen at once.
export component SliderRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <float> value;
in property <float> default-value;
in property <float> minimum;
in property <float> maximum;
in property <int> precision: 0;
in property <bool> enabled: true;
/// Fires once per completed gesture, not once per movement.
///
/// This row exists for settings-shaped values, whose handlers persist —
/// so it takes `SliderTrack`'s `committed` rather than its `changed` and
/// spares every call site the debounce. A caller that genuinely wants the
/// live stream, as the develop panel does, composes `ControlRow` with a
/// bare track instead.
callback changed(float);
callback reset();
spacing: 4px;
// What the controls draw while a drag is in flight.
//
// The committed value only arrives at the end of the gesture, so without
// this the handle would sit still under the finger for the whole drag and
// jump at release. Seeded and re-seeded imperatively rather than bound:
// Slint drops a binding on the first assignment, so a bound property would
// follow `value` until the first drag and never again.
property <float> live: root.value;
init => { root.live = root.value; }
changed value => { root.live = root.value; }
// A declared precision is a declared *step*, not merely a display format.
//
// Without this the track hands out the raw position under the finger, so a
// quality of 89.6 reads as "90" in the box — `NumberField` rounds for
// display — and arrives at a caller storing whole numbers as 89. The box
// and the stored value would disagree by one, visibly, on release. Snapping
// here makes the two the same number by construction.
property <float> step-factor: Math.pow(10, root.precision);
pure function quantise(v: float) -> float {
return Math.round(v * root.step-factor) / root.step-factor;
}
FieldRow { label: root.label; hint: root.hint; }
HorizontalLayout {
spacing: Theme.gap;
opacity: root.enabled ? 1.0 : 0.4;
SliderTrack {
horizontal-stretch: 1;
// Centred against the number box, which is a full touch target
// tall where the track is half of one.
y: (parent.height - self.height) / 2;
label: root.label;
// A declared precision is a declared step — the argument above,
// reused. The nudge an assistive technology makes is therefore the
// same quantum a drag snaps to, so arrowing to a value and
// dragging to it produce the same number rather than two that
// differ in the last place.
step: 1.0 / root.step-factor;
value: root.live;
default-value: root.default-value;
minimum: root.minimum;
maximum: root.maximum;
changed(v) => { root.live = root.quantise(v); }
committed(v) => { root.changed(root.quantise(v)); }
reset => { root.reset(); }
}
NumberField {
// Follows the drag, so the number and the handle never disagree.
value: root.live;
label: root.label;
minimum: root.minimum;
maximum: root.maximum;
precision: root.precision;
enabled: root.enabled;
// A typed entry is already a completed gesture.
changed(v) => { root.changed(v); }
}
}
}
// --- two-dimensional controls -------------------------------------------
// 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.
//
// Takes `points` directly rather than the `ParamRow` it used to read, for the
// reason given in this file's preamble: a primitive that knows the develop
// panel's model can only ever be used by the develop panel.
export component CurveEditor inherits Rectangle {
/// Control point coordinates, x and y interleaved, each 0..1 with y up.
in property <[float]> points;
/// Polyline of the curve, y 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 `SliderTrack`'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.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.points[idx * 2];
property <float> py: root.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.points[idx * 2] - 14px;
y: self.dragging ? 0px
: parent.height * (1.0 - root.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(); }
}
}
}