Build and test / Desktop (Linux) (push) Successful in 20m28s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 13m45s
Build and test / android-image (push) Successful in 13m47s
Build and test / Android (aarch64) (push) Failing after 9m16s
Two complaints from a tablet, with one cause between them. **Some sliders dragged and others only answered a tap.** They were not the same control. `ParamSlider` read its geometry from a `ParamRow` for the generated panel and `PlainSlider` took plain numbers for the straighten angle, each with its own track, handle, hit area and gesture rules written out separately — and a comment arguing the duplication was safe, because "a slider that dragged differently depending on which panel it sat in would be a worse inconsistency than the duplication". That is exactly what happened. The touch arbitration fixed in the previous commit went into `ParamSlider` and `CurveEditor`; `PlainSlider` kept the old code, so two sliders in the same sidebar behaved differently and which one you got depended on where you were dragging. The duplication failed to survive its first change. There is now one `SliderTrack`, owning the track, the hit area, the claim test, the hover arbitration, click-to-jump and double-click reset. The two wrappers differ only in where their numbers and labels come from. **Nothing in the develop column collapses any more.** Every group was a `Section` with a disclosure triangle, including five operations that carry a single parameter — so the lid was most of the row, wrapping one slider whose own label repeated the heading word for word. A control behind a lid is one the user does not know the pipeline has. `GroupHeading` keeps what the section was actually for: the name, the dot that says something inside differs from its default, and the reset. The reset is now permanently visible rather than appearing on hover, because a hover-only control is one no finger can reach. IMAGE goes back to flat as well. The column as a whole still closes, from the status strip — which is the control that was wanted, at the level it makes sense at. This costs no vertical space: `Section` defaulted to expanded and nothing ever set it otherwise, so the panel was already unfolded and the lids were overhead with no saving behind them. Verified with cargo test -p dr-ui (192), clippy at -D warnings, and an arm64-v8a release build installed on a tablet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
856 lines
35 KiB
Plaintext
856 lines
35 KiB
Plaintext
// Generic adjustment controls, generated from pipeline capabilities.
|
|
//
|
|
// **Nothing here names an operation.** There is no "exposure slider" and no
|
|
// "saturation section" — the panel walks a model the core supplies and
|
|
// instantiates one control per entry, choosing the control from the
|
|
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
|
|
// the pipeline makes it appear here with no change to this file
|
|
// (FR-DEV-3c).
|
|
|
|
import { Theme } from "theme.slint";
|
|
import { PanelHeading, Label, Value, Caption, Button, IconButton } from "widgets.slint";
|
|
|
|
// One parameter, flattened for Slint's model system.
|
|
//
|
|
// Flat rather than nested because Slint models do not nest cleanly; the Rust
|
|
// side flattens the capability tree into this and carries the indices needed
|
|
// to route a change back.
|
|
export struct ParamRow {
|
|
// Routing back to the core. Opaque to this file.
|
|
op-index: int,
|
|
param-index: int,
|
|
|
|
// Resolved display strings. Resolution happens in Rust against the UI's
|
|
// catalogue, because the core deals in localisation keys only.
|
|
op-label: string,
|
|
param-label: string,
|
|
|
|
// Grouping, derived in Rust from where `op-index` changes.
|
|
//
|
|
// The model is flat and Slint cannot slice one, so a group says where it
|
|
// begins and how long it is and the panel indexes back into `rows` from
|
|
// there. `group-head` is this row's group's first index — a row heads its
|
|
// group exactly when its own index equals it, which is what replaced the
|
|
// core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide
|
|
// that the panel has sections).
|
|
group-head: int,
|
|
group-len: int,
|
|
|
|
// Any parameter of this operation differs from its default. Identical on
|
|
// every row of a group, because the heading is one of those rows and
|
|
// cannot see the others.
|
|
group-modified: bool,
|
|
|
|
// Which control to build. Mirrors ParamKind, plus the widget kinds an
|
|
// operation can request through its presentation.
|
|
kind: string, // "scalar" | "bool" | "curve"
|
|
|
|
value: float,
|
|
default-value: float,
|
|
minimum: float,
|
|
maximum: float,
|
|
precision: int,
|
|
unit: string,
|
|
|
|
// Curve rows only: the point coordinates, x and y interleaved.
|
|
//
|
|
// Carried on the row rather than fetched separately because a Slint
|
|
// model row is the unit of update — splitting them would let the curve
|
|
// and its points refresh out of step. Empty for every other kind.
|
|
//
|
|
// `param-index` on a curve row is the index of the *first* point
|
|
// parameter, so a drag routes back by offsetting from it.
|
|
points: [float],
|
|
}
|
|
|
|
// The name of a group of controls, and what can be done to the group.
|
|
//
|
|
// What is left of `Section` once the collapsing is taken out: the name, the dot
|
|
// that says something inside differs from its default, and the reset. The
|
|
// sidebar is a column of instruments, and an instrument behind a lid is one the
|
|
// user has to remember to open — several of these held a single slider, so the
|
|
// lid was most of the row.
|
|
//
|
|
// The reset stays visible rather than appearing on hover, as the section's did.
|
|
// A hover-only control is one no finger can find, and this panel is now
|
|
// expected to be worked with a thumb.
|
|
component GroupHeading inherits Rectangle {
|
|
in property <string> title;
|
|
/// Something inside differs from its default.
|
|
in property <bool> modified: false;
|
|
/// Whether this group has anything to reset.
|
|
in property <bool> has-reset: true;
|
|
|
|
callback reset();
|
|
|
|
height: Theme.control-height;
|
|
|
|
HorizontalLayout {
|
|
padding-left: Theme.gap-sm;
|
|
padding-right: Theme.gap-sm;
|
|
spacing: Theme.gap-sm;
|
|
|
|
PanelHeading {
|
|
text: root.title;
|
|
sub: true;
|
|
horizontal-stretch: 1;
|
|
}
|
|
|
|
Rectangle {
|
|
width: 6px;
|
|
height: 6px;
|
|
y: (parent.height - self.height) / 2;
|
|
border-radius: 3px;
|
|
background: Theme.modified;
|
|
visible: root.modified;
|
|
}
|
|
|
|
Rectangle {
|
|
width: 34px;
|
|
visible: root.has-reset;
|
|
|
|
reset-touch := TouchArea {
|
|
width: 100%;
|
|
height: max(parent.height, Theme.touch-target);
|
|
y: (parent.height - self.height) / 2;
|
|
enabled: root.has-reset;
|
|
mouse-cursor: pointer;
|
|
clicked => { root.reset(); }
|
|
}
|
|
|
|
Caption {
|
|
text: "reset";
|
|
emphasised: reset-touch.has-hover;
|
|
horizontal-alignment: right;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// **The** slider. 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 below now differ only in
|
|
// where their numbers come from.
|
|
component SliderTrack inherits Rectangle {
|
|
in property <float> value;
|
|
in property <float> default-value;
|
|
in property <float> minimum;
|
|
in property <float> maximum;
|
|
|
|
callback changed(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;
|
|
|
|
function value-at(px: length) -> float {
|
|
return clamp(
|
|
root.minimum + (px / self.width) * root.span,
|
|
root.minimum,
|
|
root.maximum);
|
|
}
|
|
|
|
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) {
|
|
root.changed(self.value-at(self.mouse-x));
|
|
}
|
|
}
|
|
pointer-event(ev) => {
|
|
if (ev.kind == PointerEventKind.up
|
|
|| ev.kind == PointerEventKind.cancel) {
|
|
self.claimed = false;
|
|
}
|
|
// 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) {
|
|
root.changed(self.value-at(self.mouse-x));
|
|
}
|
|
}
|
|
double-clicked => { root.reset(); }
|
|
}
|
|
}
|
|
|
|
// One generated parameter: a label, a readout, and the track above.
|
|
component ParamSlider inherits Rectangle {
|
|
in property <ParamRow> data;
|
|
callback changed(float);
|
|
callback reset();
|
|
/// Forwarded from the track, for the panel's Flickable.
|
|
callback drag-changed(bool);
|
|
|
|
height: 46px;
|
|
|
|
VerticalLayout {
|
|
spacing: 2px;
|
|
|
|
HorizontalLayout {
|
|
Label {
|
|
text: root.data.param-label;
|
|
emphasised: root.data.value != root.data.default-value;
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
Value {
|
|
// Precision comes from the descriptor, so a control in stops
|
|
// reads 1.25 while one in whole units reads 25.
|
|
text: root.data.precision == 0
|
|
? Math.round(root.data.value) + root.data.unit
|
|
: (Math.round(root.data.value * 100) / 100) + root.data.unit;
|
|
// The one readout in the panel that has moved off its default
|
|
// is what the eye is hunting for, and `modified` is the only
|
|
// thing left to say it with once hue is gone.
|
|
modified: root.data.value != root.data.default-value;
|
|
placeholder: root.data.value == root.data.default-value;
|
|
compact: true;
|
|
}
|
|
}
|
|
|
|
SliderTrack {
|
|
value: root.data.value;
|
|
default-value: root.data.default-value;
|
|
minimum: root.data.minimum;
|
|
maximum: root.data.maximum;
|
|
|
|
changed(v) => { root.changed(v); }
|
|
reset => { root.reset(); }
|
|
engaged-changed(on) => { root.drag-changed(on); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// A slider the interface names itself, rather than one generated from a row.
|
|
//
|
|
// The straighten angle is reached through the session's own accessor, not
|
|
// through a row index, so there is no `ParamRow` to feed it. Only the labels
|
|
// and the source of the numbers differ — the track is the same component, and
|
|
// so is every gesture it recognises.
|
|
component PlainSlider inherits Rectangle {
|
|
in property <string> label;
|
|
in property <float> value;
|
|
in property <float> default-value: 0.0;
|
|
in property <float> minimum: -1.0;
|
|
in property <float> maximum: 1.0;
|
|
in property <string> unit;
|
|
|
|
callback changed(float);
|
|
callback reset();
|
|
callback drag-changed(bool);
|
|
|
|
height: 46px;
|
|
|
|
VerticalLayout {
|
|
spacing: 2px;
|
|
|
|
HorizontalLayout {
|
|
Label {
|
|
text: root.label;
|
|
emphasised: root.value != root.default-value;
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
Value {
|
|
text: (Math.round(root.value * 10) / 10) + root.unit;
|
|
modified: root.value != root.default-value;
|
|
placeholder: root.value == root.default-value;
|
|
compact: true;
|
|
}
|
|
}
|
|
|
|
SliderTrack {
|
|
value: root.value;
|
|
default-value: root.default-value;
|
|
minimum: root.minimum;
|
|
maximum: root.maximum;
|
|
|
|
changed(v) => { root.changed(v); }
|
|
reset => { root.reset(); }
|
|
engaged-changed(on) => { root.drag-changed(on); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Crop, rotation, flips and straightening — the framing controls.
|
|
//
|
|
// **Why this is hand-built when the rest of the panel is generated.** The
|
|
// generic path renders one slider per parameter, which for framing means eight
|
|
// of them: four crop edges the user would have to type coordinates into, and a
|
|
// "Rotate" slider running 0..3. Every one of those is a worse control than the
|
|
// gesture it stands for — a crop is dragged on the photograph, and a quarter
|
|
// turn is a button. So framing is presented rather than generated, and the
|
|
// generic panel drops it (see `AdjustPanel.skip-op`).
|
|
//
|
|
// This does not weaken ARCH §4.3: nothing here reads a parameter *value* out
|
|
// of a descriptor or routes by index. It calls named session actions, which is
|
|
// what a bespoke widget for a known stage is entitled to do.
|
|
export component GeometryPanel inherits Rectangle {
|
|
in property <bool> enabled: true;
|
|
/// Whether the crop overlay is up. The button is a toggle, not an action:
|
|
/// crop mode is sustained state, and the canvas looks different while it
|
|
/// is on.
|
|
in property <bool> crop-mode: false;
|
|
in property <float> angle: 0.0;
|
|
in property <float> max-straighten: 45.0;
|
|
in property <bool> flip-h: false;
|
|
in property <bool> flip-v: false;
|
|
/// Any of crop, angle, rotation or flips differs from neutral.
|
|
in property <bool> modified: false;
|
|
|
|
callback crop-toggled(bool);
|
|
callback rotate(int);
|
|
callback flip-h-toggled();
|
|
callback flip-v-toggled();
|
|
callback angle-changed(float);
|
|
callback angle-reset();
|
|
callback reset();
|
|
|
|
height: layout.preferred-height;
|
|
|
|
layout := VerticalLayout {
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// A heading, not a collapsible.
|
|
//
|
|
// These controls are the reason the column is open; folding them away
|
|
// behind a triangle put the sidebar's own contents one tap further
|
|
// from the photograph and gave every group a lid that had to be
|
|
// learned. `GroupHeading` keeps what the section was actually for —
|
|
// naming the group, flagging that it holds an edit, and offering the
|
|
// reset — without the hiding.
|
|
GroupHeading {
|
|
title: "GEOMETRY";
|
|
modified: root.modified;
|
|
has-reset: root.modified;
|
|
reset => { root.reset(); }
|
|
}
|
|
|
|
VerticalLayout {
|
|
spacing: Theme.gap-sm;
|
|
padding-bottom: Theme.gap-sm;
|
|
|
|
// Crop first: it is the framing decision the others serve.
|
|
Button {
|
|
text: root.crop-mode ? "Done Cropping" : "Crop";
|
|
active: root.crop-mode;
|
|
enabled: root.enabled;
|
|
clicked => { root.crop-toggled(!root.crop-mode); }
|
|
}
|
|
|
|
// Rotation and flips. Glyphs rather than labels: four controls
|
|
// named in words would wrap the 280px column, and each of
|
|
// these shows its own result.
|
|
HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
|
|
IconButton {
|
|
glyph: "⟲";
|
|
enabled: root.enabled;
|
|
clicked => { root.rotate(-1); }
|
|
}
|
|
IconButton {
|
|
glyph: "⟳";
|
|
enabled: root.enabled;
|
|
clicked => { root.rotate(1); }
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
IconButton {
|
|
glyph: "⇔";
|
|
active: root.flip-h;
|
|
enabled: root.enabled;
|
|
clicked => { root.flip-h-toggled(); }
|
|
}
|
|
IconButton {
|
|
glyph: "⇕";
|
|
active: root.flip-v;
|
|
enabled: root.enabled;
|
|
clicked => { root.flip-v-toggled(); }
|
|
}
|
|
}
|
|
|
|
PlainSlider {
|
|
label: "Straighten";
|
|
value: root.angle;
|
|
default-value: 0.0;
|
|
minimum: -root.max-straighten;
|
|
maximum: root.max-straighten;
|
|
unit: "°";
|
|
changed(v) => { root.angle-changed(v); }
|
|
reset => { root.angle-reset(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
component CurveEditor inherits Rectangle {
|
|
in property <ParamRow> data;
|
|
// Polyline of the curve, y values 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 `ParamSlider`'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.data.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.data.points[idx * 2];
|
|
property <float> py: root.data.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.data.points[idx * 2] - 14px;
|
|
y: self.dragging ? 0px
|
|
: parent.height * (1.0 - root.data.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(); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// The panel: a heading per multi-parameter operation, a control per parameter.
|
|
//
|
|
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
|
|
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
|
|
// repeat over an integer — `for n in row.group-len` — so each group's heading
|
|
// row renders its whole group by indexing back into `rows` from `group-head`,
|
|
// and every other row renders nothing.
|
|
//
|
|
// **Nothing here collapses.** Every group was a `Section` with a disclosure
|
|
// triangle until it became clear what that cost on a tablet: five of the
|
|
// pipeline's operations carry one parameter, so the lid was most of the row,
|
|
// and a control behind a lid is one the user does not know the pipeline has.
|
|
// The column is closed as a whole from the status strip instead, which is the
|
|
// control that was actually wanted.
|
|
export component AdjustPanel inherits Rectangle {
|
|
in property <[ParamRow]> rows;
|
|
in property <bool> enabled: true;
|
|
/// The tone curve's sampled shape, evaluated in Rust by the same spline
|
|
/// the shader runs so the drawn line cannot disagree with the applied one.
|
|
in property <[float]> curve-samples;
|
|
callback param-changed(int, int, float);
|
|
callback param-reset(int, int);
|
|
callback curve-reset(int);
|
|
/// Return every parameter of one operation to its default — the reset on
|
|
/// a section's own header, beside the panel-wide one.
|
|
callback op-reset(int);
|
|
callback reset-all();
|
|
|
|
background: Theme.surface;
|
|
|
|
// A slider below has claimed the current gesture, so this panel must stop
|
|
// competing for it. See the long note on `ParamSlider`'s `claimed`: without
|
|
// this the Flickable takes any drag that drifts 8px vertically, which under
|
|
// a finger is every drag.
|
|
private property <bool> slider-dragging: false;
|
|
|
|
VerticalLayout {
|
|
padding: Theme.gap;
|
|
spacing: Theme.gap-sm;
|
|
alignment: start;
|
|
|
|
HorizontalLayout {
|
|
PanelHeading { text: "ADJUST"; }
|
|
Rectangle { horizontal-stretch: 1; }
|
|
reset := TouchArea {
|
|
width: 44px;
|
|
height: 20px;
|
|
clicked => { root.reset-all(); }
|
|
Label {
|
|
text: "reset";
|
|
emphasised: reset.has-hover;
|
|
horizontal-alignment: right;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !root.enabled: Caption { text: "No image"; }
|
|
|
|
if root.enabled: Flickable {
|
|
viewport-height: content.preferred-height;
|
|
interactive: !root.slider-dragging;
|
|
|
|
content := VerticalLayout {
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// One iteration per row, but only a group's *first* row draws
|
|
// anything — and it draws the whole group. Every other row
|
|
// renders nothing at all.
|
|
for row[i] in root.rows: VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// **A group of one is not a group.**
|
|
//
|
|
// Five of the pipeline's operations carry a single
|
|
// parameter — exposure, contrast, saturation, vibrance,
|
|
// brilliance — and wrapping each in a Section produced a
|
|
// collapsible heading, a disclosure triangle, a modified
|
|
// dot and a hover reset around one slider, with the
|
|
// operation's name printed in caps directly above the same
|
|
// word as the slider's own label. Five times over, that is
|
|
// a column that reads as chrome with controls hidden in it.
|
|
//
|
|
// So a lone parameter is drawn bare. It loses the group
|
|
// reset, which cost nothing: the slider already resets on
|
|
// double-click and right-click, and the section's reset was
|
|
// a hover-only affordance no finger could reach anyway.
|
|
//
|
|
// `row` is the entry here — the group's head is its only
|
|
// member — so there is nothing to index back into.
|
|
if row.group-head == i && row.group-len == 1: VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
if row.kind == "scalar": ParamSlider {
|
|
data: row;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
changed(v) => {
|
|
root.param-changed(
|
|
row.op-index, row.param-index, v);
|
|
}
|
|
reset => {
|
|
root.param-reset(row.op-index, row.param-index);
|
|
}
|
|
}
|
|
|
|
if row.kind == "curve": CurveEditor {
|
|
data: row;
|
|
samples: root.curve-samples;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
point-moved(point, x, y) => {
|
|
root.param-changed(
|
|
row.op-index, row.param-index + point * 2, x);
|
|
root.param-changed(
|
|
row.op-index, row.param-index + point * 2 + 1, y);
|
|
}
|
|
reset => { root.curve-reset(row.op-index); }
|
|
}
|
|
}
|
|
|
|
// `if` rather than a zero height: a hidden-but-present
|
|
// section would still *build* its whole group, so every
|
|
// control would exist once per row of its own group —
|
|
// thirty-six live TouchAreas behind the colour mixer's
|
|
// twelve visible ones. The conditional builds nothing.
|
|
if row.group-head == i && row.group-len > 1: VerticalLayout {
|
|
spacing: 0px;
|
|
padding-top: Theme.gap-sm;
|
|
|
|
// A heading rather than a lid. Several of these groups
|
|
// are two sliders; hiding two sliders behind a triangle
|
|
// costs more than it saves, and a control the user
|
|
// cannot see is one they do not know the pipeline has.
|
|
GroupHeading {
|
|
title: row.op-label;
|
|
modified: row.group-modified;
|
|
// Resetting is what *this* panel's groups do; the
|
|
// heading itself has no opinion about it.
|
|
reset => { root.op-reset(row.op-index); }
|
|
}
|
|
|
|
// The group's own rows, addressed by offset from its
|
|
// head. `root.rows[...]` rather than the loop's `row`:
|
|
// this repeats over a count, so `n` is a number and
|
|
// the row has to be fetched.
|
|
for n in row.group-len: VerticalLayout {
|
|
property <ParamRow> entry: root.rows[row.group-head + n];
|
|
|
|
spacing: 0px;
|
|
|
|
if entry.kind == "scalar": ParamSlider {
|
|
data: entry;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
changed(v) => {
|
|
root.param-changed(
|
|
entry.op-index, entry.param-index, v);
|
|
}
|
|
reset => {
|
|
root.param-reset(entry.op-index, entry.param-index);
|
|
}
|
|
}
|
|
|
|
if entry.kind == "curve": CurveEditor {
|
|
data: entry;
|
|
samples: root.curve-samples;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
// A point carries two parameters, so the
|
|
// parameter index is the row's base plus the
|
|
// point's offset. This component still knows
|
|
// nothing about which operation it belongs to.
|
|
point-moved(point, x, y) => {
|
|
root.param-changed(
|
|
entry.op-index, entry.param-index + point * 2, x);
|
|
root.param-changed(
|
|
entry.op-index, entry.param-index + point * 2 + 1, y);
|
|
}
|
|
reset => {
|
|
root.curve-reset(entry.op-index);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: 1px;
|
|
background: Theme.rule;
|
|
}
|
|
}
|