Files
DarkRoom/ui/dr-ui/ui/controls.slint
T
dtourolleandClaude Opus 5 0a331c717e 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>
2026-08-16 23:21:35 +02:00

871 lines
36 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.
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;
/// 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);
// **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;
/// 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%;
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);
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();
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;
}
}
// 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;
callback picked(int);
spacing: 4px;
FieldRow { label: root.label; hint: root.hint; }
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 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%;
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;
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;
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(); }
}
}
}