Four faults that only show on a device, and one that was hiding on the desktop too. The system bars. Target SDK 36 forces edge-to-edge, so the window spans the display and the develop status strip was drawn underneath the clock and the wifi icons. Slint already computes the inset from Android's OnApplyWindowInsetsListener and exposes it as Window.safe-area-insets; nothing read it. The four views now sit inside a shell placed within the safe area. Every inset is zero on the desktop, so that layout does not move. Sliders under a finger. A Flickable steals any gesture that drifts more than 8 logical pixels along its scrolling axis within half a second of the press, and it steals it by cancelling the child. ParamSlider's axis test correctly declined to claim vertical drags, but nothing told the Flickable to stand down once a drag was claimed — so an adjustment would start moving and then be taken away mid-motion. A mouse holds a horizontal line closely enough to stay under 8px; a finger does not, which is why these worked on the desktop and not on the tablet. The claim now sets `interactive: false` for the rest of the gesture. The tone curve had the same fault and worse: its points are dragged vertically, which is the Flickable's own axis, so every drag was stolen — on the desktop as well. The back gesture. Nothing handled it, so back closed the application from anywhere in it. Android delivers it as Key.Back to the focused item and bubbles it up the ancestors, which is the second reason the shell wraps the views rather than sitting beside them. The order is innermost first: settings, then crop, then zoom, then develop to the grid, then a collection scope. Answering false at the top of the stack leaves Android to close the activity, as it does for every other application there. Escape does the same on a keyboard. back_step is a pure function over a flat NavState so the ordering can be tested without a backend: which of two states is left first is the whole of the feature, and it is the part that is easy to get subtly wrong when spelled out in nested ifs over live properties. Develop's canvas now takes focus on show. Without it the arrow keys did nothing until the canvas was clicked, and Key.Back had no focus item to bubble from. Panels that close. The collections sidebar and the develop column are collapsible from the grid header and the status strip. The layout class now supplies only the default: a panel closed to see more of a photograph stays closed while the window keeps its shape, and the choice is dropped when the class changes, because rotating a tablet asks a different question from the one answered in landscape. IMAGE became a Section, being the only group in the column that could not be put away and the one whose content is read first and needed least. Pinch to zoom on the develop canvas, anchored on the midpoint between the fingers (FR-UI-4). The wheel is the desktop's answer and there is no wheel on a tablet. The develop status strip was 28px against the 44px headers on the library and settings pages either side of it — the one screen where a way out has to be found was the one drawn smallest. All three now agree. Verified with cargo test -p dr-ui (192 passing, 6 new), clippy at -D warnings, and an arm64-v8a release build packaged to an APK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
778 lines
32 KiB
Plaintext
778 lines
32 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, Section, 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],
|
|
}
|
|
|
|
// A slider with a label, value readout, and double-click reset.
|
|
component ParamSlider inherits Rectangle {
|
|
in property <ParamRow> data;
|
|
callback changed(float);
|
|
callback reset();
|
|
/// Whether a drag has been claimed as a slider gesture. The panel listens
|
|
/// so it can stand its Flickable down for the rest of the gesture — see
|
|
/// the note on `claimed` below.
|
|
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;
|
|
}
|
|
}
|
|
|
|
// The track. Hand-built rather than using the standard Slint slider
|
|
// so the neutral point can be marked — a symmetric control needs to
|
|
// show where zero is.
|
|
track := Rectangle {
|
|
height: Theme.touch-target / 2;
|
|
|
|
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.
|
|
if root.data.minimum < root.data.default-value
|
|
&& root.data.default-value < root.data.maximum: Rectangle {
|
|
x: (root.data.default-value - root.data.minimum)
|
|
/ (root.data.maximum - root.data.minimum) * 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 <float> span: root.data.maximum - root.data.minimum;
|
|
property <length> default-x:
|
|
(root.data.default-value - root.data.minimum) / self.span * parent.width;
|
|
property <length> value-x:
|
|
(root.data.value - root.data.minimum) / self.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;
|
|
}
|
|
|
|
handle := Rectangle {
|
|
x: (root.data.value - root.data.minimum)
|
|
/ (root.data.maximum - root.data.minimum) * 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%;
|
|
|
|
property <float> span: root.data.maximum - root.data.minimum;
|
|
|
|
// Whether this gesture has been claimed as a slider drag.
|
|
//
|
|
// The panel scrolls 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 the panel is sliders.
|
|
//
|
|
// **Claiming has to stand the Flickable down, not just gate
|
|
// this handler.** Slint's Flickable steals any gesture that
|
|
// wanders more than 8 logical pixels along its scrolling axis
|
|
// within half a second of the press, and it steals it by
|
|
// cancelling the child — so a drag that began here is taken
|
|
// away mid-motion. A mouse tracks a horizontal line closely
|
|
// enough to stay under that; a finger does not, which is
|
|
// exactly why these worked on the desktop and not on the
|
|
// tablet. Reporting the claim lets the panel set
|
|
// `interactive: false` for the rest of the gesture, after
|
|
// which the Flickable stops arbitrating and the drag survives
|
|
// however far the finger drifts.
|
|
property <bool> claimed: false;
|
|
changed claimed => { root.drag-changed(self.claimed); }
|
|
|
|
function value-at(px: length) -> float {
|
|
return clamp(
|
|
root.data.minimum + (px / self.width) * self.span,
|
|
root.data.minimum,
|
|
root.data.maximum);
|
|
}
|
|
|
|
moved => {
|
|
// `moved` fires only while pressed, so this is a drag.
|
|
if (!self.claimed) {
|
|
// Claim once the movement is more horizontal than
|
|
// vertical. Until then it might still be a scroll.
|
|
if (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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A slider not backed by a `ParamRow`.
|
|
//
|
|
// `ParamSlider` reads its geometry out of a model row, which is right for the
|
|
// generated panel and wrong for a control the interface names itself — the
|
|
// straighten angle is reached through the session's own accessor, not through
|
|
// a row index, so there is no row to feed it. The track behaviour is the same
|
|
// and deliberately so: a slider that dragged differently depending on which
|
|
// panel it sat in would be a worse inconsistency than the duplication.
|
|
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();
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
track := Rectangle {
|
|
height: Theme.touch-target / 2;
|
|
|
|
Rectangle {
|
|
y: (parent.height - 3px) / 2;
|
|
height: 3px;
|
|
background: Theme.surface-raised;
|
|
border-radius: 1.5px;
|
|
}
|
|
|
|
// The neutral point. A straighten slider is symmetric and read as
|
|
// a deviation from level, so where zero sits has to be visible.
|
|
if root.minimum < root.default-value && root.default-value < root.maximum:
|
|
Rectangle {
|
|
x: (root.default-value - root.minimum)
|
|
/ (root.maximum - root.minimum) * parent.width - 1px;
|
|
y: (parent.height - 9px) / 2;
|
|
width: 2px;
|
|
height: 9px;
|
|
background: Theme.rule;
|
|
}
|
|
|
|
Rectangle {
|
|
property <float> span: root.maximum - root.minimum;
|
|
property <length> default-x:
|
|
(root.default-value - root.minimum) / self.span * parent.width;
|
|
property <length> value-x:
|
|
(root.value - root.minimum) / self.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;
|
|
background: Theme.active;
|
|
border-radius: 1.5px;
|
|
}
|
|
|
|
Rectangle {
|
|
x: (root.value - root.minimum)
|
|
/ (root.maximum - root.minimum) * 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 {
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
property <float> span: root.maximum - root.minimum;
|
|
// The same axis test `ParamSlider` makes, and for the same
|
|
// reason: this sits in a scrolling panel, and claiming the
|
|
// gesture on press-down would jump the angle every time the
|
|
// user tried to scroll from it.
|
|
property <bool> claimed: false;
|
|
|
|
function value-at(px: length) -> float {
|
|
return clamp(
|
|
root.minimum + (px / self.width) * self.span,
|
|
root.minimum,
|
|
root.maximum);
|
|
}
|
|
|
|
moved => {
|
|
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;
|
|
}
|
|
if (ev.kind == PointerEventKind.down
|
|
&& ev.button == PointerEventButton.right) {
|
|
root.reset();
|
|
}
|
|
}
|
|
clicked => {
|
|
if (!self.claimed) {
|
|
root.changed(self.value-at(self.mouse-x));
|
|
}
|
|
}
|
|
double-clicked => { root.reset(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
Section {
|
|
title: "GEOMETRY";
|
|
modified: root.modified;
|
|
has-reset: root.modified;
|
|
op-reset => { root.reset(); }
|
|
|
|
VerticalLayout {
|
|
spacing: Theme.gap-sm;
|
|
padding-top: 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();
|
|
/// A point is being dragged. The panel stands its Flickable down while it
|
|
/// is, for the reason spelled out on `ParamSlider`'s `claimed` — and more
|
|
/// acutely here, because a curve point is dragged *vertically*, which is
|
|
/// the Flickable's own axis and so is stolen every single time.
|
|
callback drag-changed(bool);
|
|
|
|
property <int> point-count: root.data.points.length / 2;
|
|
|
|
changed active-point => { root.drag-changed(root.active-point >= 0); }
|
|
|
|
// 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 collapsible section per 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. That puts the group's controls genuinely
|
|
// *inside* its `Section`, which is what makes collapse a matter of the section
|
|
// clipping its own body rather than each row hiding itself.
|
|
//
|
|
// It also means collapse state is the `Section`'s own — one `expanded` per
|
|
// repeated element, keyed by position and so by `op-index`, never by label.
|
|
// Slint keeps that state across row-*data* updates, which is what lets a
|
|
// collapsed section stay collapsed while a slider elsewhere is dragged: the
|
|
// panel is re-fed on every drag event.
|
|
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;
|
|
|
|
// `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: Section {
|
|
title: row.op-label;
|
|
modified: row.group-modified;
|
|
|
|
// The section's reset. Placed here rather than in
|
|
// `Section` itself because resetting is what *this*
|
|
// panel's sections do; a section in another panel may
|
|
// have nothing to reset.
|
|
op-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;
|
|
}
|
|
}
|