// 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 value; in property default-value; in property minimum; in property 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 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 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 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 span: max(0.000001, root.maximum - root.minimum); property 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 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 default-x: (root.default-value - root.minimum) / root.span * parent.width; property 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 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 pending-value; property 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 value; in property minimum; in property maximum; /// What the number means. Handed straight to the entry, which is where the /// accessibility tree wants it — see `Field.label`. in property 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 precision: 0; in property enabled: true; callback changed(float); width: 72px; height: Theme.touch-target; horizontal-stretch: 0; opacity: root.enabled ? 1.0 : 0.4; property 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 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 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 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 hint; in-out property 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 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 label; in property selected: false; in property 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 label; in property 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 selected: -1; in property enabled: true; /// How many chips to a row. Three is what fits the narrowest column the /// application supports. in property columns: 3; /// Comfortable for a one-word label at `Theme.text`, and three of them /// plus their gaps fit a 280px column. in property chip-width: 88px; callback picked(int); property rows: max(1, ceil(root.options.length / max(1, root.columns))); property 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 label; in property hint; in property <[string]> options; in property selected: 0; in property 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 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 label; in property hint; in-out property text; in property unit; in property placeholder; in property enabled: true; in property 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 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 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 readout; in property 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 label; in property hint; in property value; in property default-value; in property minimum; in property maximum; in property precision: 0; in property 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 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 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 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 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 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 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 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 exists: idx < root.point-count; property px: root.points[idx * 2]; property 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 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 exists: idx < root.point-count; property 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(); } } } }