Make one slider, and take the lids off the develop column
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
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>
This commit is contained in:
@@ -87,7 +87,7 @@ _None._
|
|||||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) |
|
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) |
|
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) |
|
||||||
| FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:966`](../ui/dr-ui/ui/app.slint#L966) |
|
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:950`](../ui/dr-ui/ui/app.slint#L950) |
|
||||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) |
|
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) |
|
||||||
|
|||||||
+338
-260
@@ -8,7 +8,7 @@
|
|||||||
// (FR-DEV-3c).
|
// (FR-DEV-3c).
|
||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { PanelHeading, Label, Value, Caption, Section, Button, IconButton } from "widgets.slint";
|
import { PanelHeading, Label, Value, Caption, Button, IconButton } from "widgets.slint";
|
||||||
|
|
||||||
// One parameter, flattened for Slint's model system.
|
// One parameter, flattened for Slint's model system.
|
||||||
//
|
//
|
||||||
@@ -63,14 +63,237 @@ export struct ParamRow {
|
|||||||
points: [float],
|
points: [float],
|
||||||
}
|
}
|
||||||
|
|
||||||
// A slider with a label, value readout, and double-click reset.
|
// 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 {
|
component ParamSlider inherits Rectangle {
|
||||||
in property <ParamRow> data;
|
in property <ParamRow> data;
|
||||||
callback changed(float);
|
callback changed(float);
|
||||||
callback reset();
|
callback reset();
|
||||||
/// Whether a drag has been claimed as a slider gesture. The panel listens
|
/// Forwarded from the track, for the panel's Flickable.
|
||||||
/// so it can stand its Flickable down for the rest of the gesture — see
|
|
||||||
/// the note on `claimed` below.
|
|
||||||
callback drag-changed(bool);
|
callback drag-changed(bool);
|
||||||
|
|
||||||
height: 46px;
|
height: 46px;
|
||||||
@@ -101,151 +324,25 @@ component ParamSlider inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The track. Hand-built rather than using the standard Slint slider
|
SliderTrack {
|
||||||
// so the neutral point can be marked — a symmetric control needs to
|
value: root.data.value;
|
||||||
// show where zero is.
|
default-value: root.data.default-value;
|
||||||
track := Rectangle {
|
minimum: root.data.minimum;
|
||||||
height: Theme.touch-target / 2;
|
maximum: root.data.maximum;
|
||||||
|
|
||||||
Rectangle {
|
changed(v) => { root.changed(v); }
|
||||||
y: (parent.height - 3px) / 2;
|
reset => { root.reset(); }
|
||||||
height: 3px;
|
engaged-changed(on) => { root.drag-changed(on); }
|
||||||
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`.
|
// A slider the interface names itself, rather than one generated from a row.
|
||||||
//
|
//
|
||||||
// `ParamSlider` reads its geometry out of a model row, which is right for the
|
// The straighten angle is reached through the session's own accessor, not
|
||||||
// generated panel and wrong for a control the interface names itself — the
|
// through a row index, so there is no `ParamRow` to feed it. Only the labels
|
||||||
// straighten angle is reached through the session's own accessor, not through
|
// and the source of the numbers differ — the track is the same component, and
|
||||||
// a row index, so there is no row to feed it. The track behaviour is the same
|
// so is every gesture it recognises.
|
||||||
// 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 {
|
component PlainSlider inherits Rectangle {
|
||||||
in property <string> label;
|
in property <string> label;
|
||||||
in property <float> value;
|
in property <float> value;
|
||||||
@@ -256,6 +353,7 @@ component PlainSlider inherits Rectangle {
|
|||||||
|
|
||||||
callback changed(float);
|
callback changed(float);
|
||||||
callback reset();
|
callback reset();
|
||||||
|
callback drag-changed(bool);
|
||||||
|
|
||||||
height: 46px;
|
height: 46px;
|
||||||
|
|
||||||
@@ -278,98 +376,15 @@ component PlainSlider inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
track := Rectangle {
|
SliderTrack {
|
||||||
height: Theme.touch-target / 2;
|
value: root.value;
|
||||||
|
default-value: root.default-value;
|
||||||
|
minimum: root.minimum;
|
||||||
|
maximum: root.maximum;
|
||||||
|
|
||||||
Rectangle {
|
changed(v) => { root.changed(v); }
|
||||||
y: (parent.height - 3px) / 2;
|
reset => { root.reset(); }
|
||||||
height: 3px;
|
engaged-changed(on) => { root.drag-changed(on); }
|
||||||
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(); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,15 +429,23 @@ export component GeometryPanel inherits Rectangle {
|
|||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
|
|
||||||
Section {
|
// 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";
|
title: "GEOMETRY";
|
||||||
modified: root.modified;
|
modified: root.modified;
|
||||||
has-reset: root.modified;
|
has-reset: root.modified;
|
||||||
op-reset => { root.reset(); }
|
reset => { root.reset(); }
|
||||||
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: Theme.gap-sm;
|
spacing: Theme.gap-sm;
|
||||||
padding-top: Theme.gap-sm;
|
|
||||||
padding-bottom: Theme.gap-sm;
|
padding-bottom: Theme.gap-sm;
|
||||||
|
|
||||||
// Crop first: it is the framing decision the others serve.
|
// Crop first: it is the framing decision the others serve.
|
||||||
@@ -478,7 +501,6 @@ export component GeometryPanel inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tone curve editor: a square grid with draggable control points.
|
// A tone curve editor: a square grid with draggable control points.
|
||||||
@@ -497,15 +519,19 @@ component CurveEditor inherits Rectangle {
|
|||||||
|
|
||||||
callback point-moved(int, float, float);
|
callback point-moved(int, float, float);
|
||||||
callback reset();
|
callback reset();
|
||||||
/// A point is being dragged. The panel stands its Flickable down while it
|
/// The pointer is on a control point. The panel stands its Flickable down
|
||||||
/// is, for the reason spelled out on `ParamSlider`'s `claimed` — and more
|
/// while it is, for the reason spelled out on `ParamSlider`'s `engaged` —
|
||||||
/// acutely here, because a curve point is dragged *vertically*, which is
|
/// and more acutely here, because a curve point is dragged *vertically*,
|
||||||
/// the Flickable's own axis and so is stolen every single time.
|
/// which is the Flickable's own axis and so is contested every time.
|
||||||
callback drag-changed(bool);
|
callback drag-changed(bool);
|
||||||
|
|
||||||
property <int> point-count: root.data.points.length / 2;
|
property <int> point-count: root.data.points.length / 2;
|
||||||
|
|
||||||
changed active-point => { root.drag-changed(root.active-point >= 0); }
|
// 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,
|
// The point the pointer is over, or -1. Set by the grab targets below,
|
||||||
// and used only to highlight the marker.
|
// and used only to highlight the marker.
|
||||||
@@ -636,21 +662,20 @@ component CurveEditor inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The panel: a collapsible section per operation, a control per parameter.
|
// 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
|
// **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
|
// 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
|
// 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`,
|
// 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
|
// and every other row renders nothing.
|
||||||
// *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
|
// **Nothing here collapses.** Every group was a `Section` with a disclosure
|
||||||
// repeated element, keyed by position and so by `op-index`, never by label.
|
// triangle until it became clear what that cost on a tablet: five of the
|
||||||
// Slint keeps that state across row-*data* updates, which is what lets a
|
// pipeline's operations carry one parameter, so the lid was most of the row,
|
||||||
// collapsed section stay collapsed while a slider elsewhere is dragged: the
|
// and a control behind a lid is one the user does not know the pipeline has.
|
||||||
// panel is re-fed on every drag event.
|
// 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 {
|
export component AdjustPanel inherits Rectangle {
|
||||||
in property <[ParamRow]> rows;
|
in property <[ParamRow]> rows;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
@@ -709,20 +734,73 @@ export component AdjustPanel inherits Rectangle {
|
|||||||
for row[i] in root.rows: VerticalLayout {
|
for row[i] in root.rows: VerticalLayout {
|
||||||
spacing: 0px;
|
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
|
// `if` rather than a zero height: a hidden-but-present
|
||||||
// section would still *build* its whole group, so every
|
// section would still *build* its whole group, so every
|
||||||
// control would exist once per row of its own group —
|
// control would exist once per row of its own group —
|
||||||
// thirty-six live TouchAreas behind the colour mixer's
|
// thirty-six live TouchAreas behind the colour mixer's
|
||||||
// twelve visible ones. The conditional builds nothing.
|
// twelve visible ones. The conditional builds nothing.
|
||||||
if row.group-head == i: Section {
|
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;
|
title: row.op-label;
|
||||||
modified: row.group-modified;
|
modified: row.group-modified;
|
||||||
|
// Resetting is what *this* panel's groups do; the
|
||||||
// The section's reset. Placed here rather than in
|
// heading itself has no opinion about it.
|
||||||
// `Section` itself because resetting is what *this*
|
reset => { root.op-reset(row.op-index); }
|
||||||
// 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
|
// The group's own rows, addressed by offset from its
|
||||||
// head. `root.rows[...]` rather than the loop's `row`:
|
// head. `root.rows[...]` rather than the loop's `row`:
|
||||||
|
|||||||
+12
-28
@@ -2,7 +2,7 @@ import { Theme } from "theme.slint";
|
|||||||
import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint";
|
import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint";
|
||||||
import { LaunchScreen } from "launch.slint";
|
import { LaunchScreen } from "launch.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||||
import { Button, Label, Value, Caption, Section, EmptyState } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
||||||
import { SettingsPage } from "settings.slint";
|
import { SettingsPage } from "settings.slint";
|
||||||
|
|
||||||
@@ -117,39 +117,25 @@ component StatusBar inherits Rectangle {
|
|||||||
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
||||||
// which is generated from pipeline capabilities rather than written here.
|
// which is generated from pipeline capabilities rather than written here.
|
||||||
//
|
//
|
||||||
// A `Section` rather than the flat `Panel` it used to be, so this collapses
|
// Flat, like everything else in this column. This was briefly a collapsible
|
||||||
// like everything else in the column does. It was the one group that could not
|
// section so that every panel could be put away; the sidebar as a whole now
|
||||||
// be put away, and it is also the one whose content the user stops needing
|
// closes from the status strip instead, which is the control that was actually
|
||||||
// first: which body took the photograph is read once and then occupies the top
|
// wanted, and the per-group lids only stood between the user and the controls.
|
||||||
// of the panel for the rest of the edit. Structurally identical to
|
|
||||||
// `GeometryPanel` below — same wrapper, same height binding — so the two read
|
|
||||||
// as the same kind of thing, which they now are.
|
|
||||||
//
|
|
||||||
// `has-reset` is off: there is nothing here to undo. The section's heading
|
|
||||||
// keeps its own collapse state across model updates, which is what lets a
|
|
||||||
// closed IMAGE stay closed while a slider elsewhere is dragged.
|
|
||||||
component InfoPanel inherits Rectangle {
|
component InfoPanel inherits Rectangle {
|
||||||
in property <string> camera;
|
in property <string> camera;
|
||||||
in property <string> exposure;
|
in property <string> exposure;
|
||||||
in property <string> dimensions;
|
in property <string> dimensions;
|
||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
height: layout.preferred-height;
|
height: panel.preferred-height;
|
||||||
|
|
||||||
layout := VerticalLayout {
|
panel := Panel {
|
||||||
spacing: 0px;
|
// Flat: this abuts the adjust panel below it and the rule between
|
||||||
alignment: start;
|
// them is drawn by the column that stacks the two.
|
||||||
|
flat: true;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
Section {
|
PanelHeading { text: "IMAGE"; }
|
||||||
title: "IMAGE";
|
|
||||||
has-reset: false;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: Theme.gap-sm;
|
|
||||||
padding-top: Theme.gap-sm;
|
|
||||||
padding-bottom: Theme.gap-sm;
|
|
||||||
padding-left: Theme.gap-sm;
|
|
||||||
padding-right: Theme.gap-sm;
|
|
||||||
|
|
||||||
Value {
|
Value {
|
||||||
text: root.camera == "" ? "—" : root.camera;
|
text: root.camera == "" ? "—" : root.camera;
|
||||||
@@ -161,8 +147,6 @@ component InfoPanel inherits Rectangle {
|
|||||||
|
|
||||||
Caption { text: root.dimensions; }
|
Caption { text: root.dimensions; }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
export component AppWindow inherits Window {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Section } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, Field } from "widgets.slint";
|
||||||
|
|
||||||
// Settings: how much disk the app may spend, and what an export defaults to.
|
// Settings: how much disk the app may spend, and what an export defaults to.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user