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>
871 lines
36 KiB
Plaintext
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(); }
|
|
}
|
|
}
|
|
}
|