Files
DarkRoom/ui/dr-ui/ui/controls.slint
T
dtourolle 9cc52fd72b Bind the two develop gestures that were described and not bound
FR-DEV-16's book said resetting a control and hiding a mask layer were
reachable by pointer and by finger, and stopped there. The reason was
honest: the generated rows have no focus, so "reset the focused control"
named a thing the panel could not point at. But a photographer at the
keyboard means something narrower than focus. They mean the slider they
just dragged too far, and that is a thing the panel can remember.

So the Adjustments global keeps the last control moved — two indices,
written where the panel forwards the change and cleared when the next
photograph opens, so a reset cannot reach back into the previous edit
through an index that happens to be shared. R puts it back, through the
same callback the track's double-click takes, and is silent until
something has moved.

The mask layer needs no such notion, because the panel already has a
selection: the rows the edge controls point at. H hides or shows those,
through the path the ring at the head of the row takes, so it is an edit
and a history step exactly as the ring is. A mixed selection goes to
shown, since the layer nobody can see is the one being asked about.

Both tags now carry the key, and the book says so.
2026-09-12 01:08:09 +02:00

1108 lines
47 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();
}
// GESTURE: Put one control back to its default
// where: Develop
// touch: Double-tap its track
// pointer: Double-click its track, or right-click it
// keys: R, for the control last moved
// why: The column is 280px wide and the colour mixer alone
// puts thirty-six of these in it, so a reset button per
// row would be most of the width. Two ways in with a
// pointer because right-click is the one a hand already
// reaches for and double-click is the one that needs no
// second button. A group's own reset is in its heading;
// this is the single control.
//
// 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;
/// Whether the tick is decided here or by whoever supplied `checked`.
///
/// A settings page and a generated panel want opposite things, and the
/// difference is not cosmetic. A page owns its switches: ticking one is
/// the whole event, and the box flipping under the finger is the feedback.
/// A panel row is a *view of a model* — the value lives in the edit graph,
/// the same graph an undo step or a pasted preset can move — so a box that
/// set its own state would answer the click with a binding replaced by a
/// literal, and the next time the value changed from anywhere else the
/// tick would stay where the finger left it.
///
/// Controlled, the click is reported and nothing else happens; the tick
/// follows `checked`, which is what it was always drawing.
in property <bool> controlled: false;
callback toggled(bool);
// One toggle, called from the pointer and from the accessibility action
// alike, so the two cannot drift — the keyboard route had already been
// written twice.
function toggle() {
if (root.controlled) {
root.toggled(!root.checked);
} else {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
// 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.toggle(); }
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.toggle(); }
}
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(); }
}
}
}