// 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 value; in property default-value; in property minimum; in property 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 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 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(); } // 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; /// 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%; 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; 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 label; in property selected: false; in property 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 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; } } // 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; 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 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%; 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; 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 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(); } } } }