Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a row with the adjustment groups and told apart from them by the shape of their highlight. Three things followed from that, and only the last is cosmetic: the column closes, so the way out of a mode went away with the way in — hence the duplicate "Done Cropping" over the canvas; the chips are generated from the operation set, so the widest thing in the sidebar was a row nobody had chosen the contents of; and a mode and a filter are different kinds of state wearing one control. They are a fixed 60px rail down the left now, generated from a single table in toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant; nothing in app.slint is touched to add one. What is left of the strip is the group filters, so it is GroupStrip. The column stops measuring itself. Every panel published a content-width and declared it as min-width, and the column took the largest — which spent the photograph's pixels on whatever happened to be widest, and moved the image sideways when switching tools swapped one set of panels for another. It is panel-width now, one number in style.yaml. That number is 360 and it is measured, not picked: the contents report a minimum of 344 in every mode, and they do not compress below it because a Text that does not elide reports the same minimum as preferred. 320 was tried and sliced Paste down the middle. The Flickable's viewport is floored at the layout's minimum rather than its preferred width for the same reason — content that is never told how much room it has cannot adapt to having less. Removing the eight content-width declarations repairs three comments an earlier edit had spliced sentences into. The raw histogram's note on keeping its hint short is rewritten rather than dropped: an over-long hint no longer widens the column, it pushes the column's minimum past the width it has and clips the panel, which makes that constraint sharper rather than obsolete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
946 lines
39 KiB
Plaintext
946 lines
39 KiB
Plaintext
// The control vocabulary: everything that takes input.
|
|
//
|
|
// **Why this is a second file rather than more of widgets.slint.** That file
|
|
// establishes the rule — screens consume components, and a bare `Theme.*` at a
|
|
// call site means a component is missing — and it establishes it for *chrome*:
|
|
// buttons, panels, headings, the text roles. Chrome is drawn and read. What is
|
|
// below is dragged, typed into and toggled, which is a different kind of thing
|
|
// with a different set of concerns: gesture arbitration, validation, hit
|
|
// targets, and what a control does when the value moves under it. Keeping them
|
|
// apart is what lets either file stay readable.
|
|
//
|
|
// **The rule this file establishes.** A control's *behaviour* is written once,
|
|
// here, and its numbers come from the caller. Before this file the slider lived
|
|
// inside the develop panel and was reachable from nowhere else, so the settings
|
|
// page — which has a 1-to-100 quality and three other bounded numbers — offered
|
|
// a free-text box instead, and `to-float()` silently turned a typo into zero. A
|
|
// primitive that only one screen can reach is not a primitive.
|
|
//
|
|
// **Nothing here knows about `ParamRow`.** That struct is the develop panel's
|
|
// flattening of the capability model and lives in adjust.slint; a control that
|
|
// imported it would drag the whole develop model into the settings page. The
|
|
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers
|
|
// stay in the panel that owns the model. This is the constraint that makes the
|
|
// file reusable, so it is worth stating rather than merely observing.
|
|
|
|
import { Theme } from "theme.slint";
|
|
import { Icon, Label, Value, Caption, Field } from "widgets.slint";
|
|
|
|
// --- the slider ---------------------------------------------------------
|
|
|
|
// **The** slider track. One track, one hit area, one set of gesture rules.
|
|
//
|
|
// This exists because there used to be two of these, written out separately —
|
|
// one reading its geometry from a `ParamRow` for the generated panel, one
|
|
// taking plain numbers for the straighten angle — with a comment claiming the
|
|
// duplication was safe because "the track behaviour is the same and
|
|
// deliberately so". It was not safe and did not stay the same: the moment the
|
|
// touch arbitration below was fixed in one copy, the two sliders in the same
|
|
// sidebar started behaving differently, and which one you got depended on which
|
|
// panel you happened to be dragging in. The wrappers now differ only in where
|
|
// their numbers come from.
|
|
//
|
|
// It moved here from adjust.slint unchanged. The argument above is the reason
|
|
// the move matters: a track private to one panel is a track the next screen
|
|
// reimplements, which is the same failure one level up.
|
|
export component SliderTrack inherits Rectangle {
|
|
in property <float> value;
|
|
in property <float> default-value;
|
|
in property <float> minimum;
|
|
in property <float> maximum;
|
|
|
|
/// Live, once per movement. For anything that should follow the drag: a
|
|
/// readout, a preview, the image itself.
|
|
callback changed(float);
|
|
/// The gesture is over and this is the value to keep.
|
|
///
|
|
/// **Two callbacks because there are two costs.** Re-rendering a
|
|
/// photograph on every movement is the entire point of a develop slider,
|
|
/// and the pipeline is built for it. Writing a settings file on every
|
|
/// movement is not: a two-second drag is a couple of hundred serialise-
|
|
/// and-save round trips where a text field committed once, and on Android
|
|
/// that goes through the Storage Access Framework. So a caller whose
|
|
/// handler is cheap listens to `changed`, and one whose handler is
|
|
/// expensive listens to this — rather than every such caller inventing its
|
|
/// own debounce, which is how two of them come to disagree about when an
|
|
/// edit is finished.
|
|
callback committed(float);
|
|
callback reset();
|
|
/// The pointer is on this control. A scrolling ancestor listens so it can
|
|
/// stand down — see the note on `engaged` below.
|
|
callback engaged-changed(bool);
|
|
|
|
height: Theme.touch-target / 2;
|
|
|
|
// Guarded, because a descriptor with a zero range would otherwise divide
|
|
// by nothing and put every position at infinity.
|
|
property <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;
|
|
|
|
// The last value this gesture emitted, held until it is committed.
|
|
//
|
|
// Kept here rather than read back from `root.value` at release: the
|
|
// parent may or may not have fed the live value back down, and a
|
|
// commit that reported the *old* number on a caller who ignored
|
|
// `changed` would silently save the wrong thing.
|
|
property <float> pending-value;
|
|
property <bool> pending: false;
|
|
|
|
function value-at(px: length) -> float {
|
|
return clamp(
|
|
root.minimum + (px / self.width) * root.span,
|
|
root.minimum,
|
|
root.maximum);
|
|
}
|
|
|
|
function emit(v: float) {
|
|
self.pending-value = v;
|
|
self.pending = true;
|
|
root.changed(v);
|
|
}
|
|
|
|
// Idempotent, because both the pointer-up and the `clicked` that
|
|
// follows it are legitimate places to notice a gesture has ended and
|
|
// only one of them should commit.
|
|
function commit() {
|
|
if (self.pending) {
|
|
self.pending = false;
|
|
root.committed(self.pending-value);
|
|
}
|
|
}
|
|
|
|
moved => {
|
|
// `moved` fires only while pressed, so this is a drag.
|
|
if (!self.claimed
|
|
&& abs(self.mouse-x - self.pressed-x)
|
|
> abs(self.mouse-y - self.pressed-y)) {
|
|
self.claimed = true;
|
|
}
|
|
if (self.claimed) {
|
|
self.emit(self.value-at(self.mouse-x));
|
|
}
|
|
}
|
|
pointer-event(ev) => {
|
|
if (ev.kind == PointerEventKind.up
|
|
|| ev.kind == PointerEventKind.cancel) {
|
|
self.claimed = false;
|
|
// A drag ending outside the track never produces `clicked`, so
|
|
// the commit cannot wait for one.
|
|
self.commit();
|
|
}
|
|
// Right-click resets, alongside double-click.
|
|
if (ev.kind == PointerEventKind.down
|
|
&& ev.button == PointerEventButton.right) {
|
|
root.reset();
|
|
}
|
|
}
|
|
clicked => {
|
|
// A press with no meaningful drag: jump to it. Handled on release
|
|
// rather than on press so it cannot fire during a scroll that
|
|
// merely started here.
|
|
if (!self.claimed) {
|
|
self.emit(self.value-at(self.mouse-x));
|
|
}
|
|
self.commit();
|
|
}
|
|
double-clicked => { root.reset(); }
|
|
}
|
|
}
|
|
|
|
// --- numeric entry ------------------------------------------------------
|
|
|
|
// A number typed rather than dragged, held to a declared range and precision.
|
|
//
|
|
// **The parsing is the point.** `Field` plus `to-float()` — which is what the
|
|
// settings page did before this existed — cannot tell a rejected entry from a
|
|
// deliberate zero, because `to-float()` answers 0 for both. So a mistyped
|
|
// export quality silently became 0, was saved, and the page then displayed the
|
|
// 0 as though the user had asked for it. `is-float()` is the missing question,
|
|
// and asking it is the whole reason this is a component and not a `Field` with
|
|
// a call-site handler.
|
|
//
|
|
// **A rejected entry reverts rather than erroring.** There is nowhere to put a
|
|
// validation message on a settings row that would not push every control below
|
|
// it down the page, and a control that rejects input by snapping back to the
|
|
// last good value has already said what happened. Out-of-range is different
|
|
// from unparseable and is *not* rejected: 500 in a 1-to-100 field is a clear
|
|
// intention, so it clamps to 100 and shows the 100, which is both what the
|
|
// pipeline received and an answer to "why did that not take".
|
|
export component NumberField inherits Rectangle {
|
|
in property <float> value;
|
|
in property <float> minimum;
|
|
in property <float> maximum;
|
|
/// Decimal places shown, and the precision an entry is held to. Zero for a
|
|
/// count, two for a value in stops — the same figure a parameter
|
|
/// descriptor declares.
|
|
in property <int> precision: 0;
|
|
in property <bool> enabled: true;
|
|
|
|
callback changed(float);
|
|
|
|
width: 72px;
|
|
height: Theme.touch-target;
|
|
horizontal-stretch: 0;
|
|
opacity: root.enabled ? 1.0 : 0.4;
|
|
|
|
property <float> factor: Math.pow(10, root.precision);
|
|
|
|
pure function shown(v: float) -> string {
|
|
return Math.round(v * root.factor) / root.factor + "";
|
|
}
|
|
|
|
// The text in the box, deliberately *not* a binding on `value`.
|
|
//
|
|
// A binding would be broken permanently the first time the TextInput wrote
|
|
// through it — Slint drops a binding on assignment — so the box would
|
|
// follow the value until the first keystroke and never again. Seeded on
|
|
// `init` and rewritten from the two events where rewriting is correct:
|
|
// when the value moves under the box (a slider drag, a reset elsewhere),
|
|
// and when an entry is committed.
|
|
in-out property <string> text;
|
|
init => { root.text = root.shown(root.value); }
|
|
changed value => { root.text = root.shown(root.value); }
|
|
|
|
function commit() {
|
|
if (root.text.is-float()) {
|
|
root.changed(clamp(root.text.to-float(), root.minimum, root.maximum));
|
|
}
|
|
// Rewritten either way. A valid entry is echoed back clamped and at the
|
|
// declared precision, so the box always shows the number the pipeline
|
|
// actually holds; an invalid one is discarded and the last good value
|
|
// returns.
|
|
root.text = root.shown(root.value);
|
|
}
|
|
|
|
field := Field {
|
|
width: 100%;
|
|
height: 100%;
|
|
text <=> root.text;
|
|
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter
|
|
// alone loses the edit the moment the user clicks the next control,
|
|
// which on a page that saves continuously reads as the setting not
|
|
// having taken.
|
|
accepted => { root.commit(); }
|
|
}
|
|
|
|
property <bool> focused: field.has-focus;
|
|
changed focused => {
|
|
if (!self.focused) {
|
|
root.commit();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- booleans -----------------------------------------------------------
|
|
|
|
// A tick-box and its label: one setting that is either on or off.
|
|
//
|
|
// Written twice before this — `FormatCheck` in launch.slint and `Switch` in
|
|
// settings.slint — from the same 18px box with the same `active` fill and the
|
|
// same toggle handler. The second copy carried a comment saying the lift
|
|
// belonged here "once a third caller appears", which was the right instinct
|
|
// and the wrong threshold: the two copies had already drifted in height, and
|
|
// the third caller is this file establishing what a boolean looks like.
|
|
//
|
|
// **A ticked box fills rather than merely outlining.** `active` is the token
|
|
// for an engaged control, and it is what the slider fill and the focus border
|
|
// take, so a checked box reads as the same kind of state as those.
|
|
export component Check inherits Rectangle {
|
|
in property <string> label;
|
|
/// Why the default is what it is, for settings where the consequence is
|
|
/// not obvious from the name — upscaling being off, location being
|
|
/// stripped. Empty on a box whose label says it already; a page of bare
|
|
/// switches makes the user guess at what each one costs.
|
|
in property <string> hint;
|
|
in-out property <bool> checked;
|
|
|
|
callback toggled(bool);
|
|
|
|
height: max(row.preferred-height, Theme.control-height);
|
|
|
|
touch := TouchArea {
|
|
// FR-UI-3: the drawn row is shorter than a touch target, so the target
|
|
// grows past its own bounds rather than the ink growing.
|
|
height: max(parent.height, Theme.touch-target);
|
|
y: (parent.height - self.height) / 2;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.checked = !root.checked;
|
|
root.toggled(root.checked);
|
|
}
|
|
}
|
|
|
|
row := HorizontalLayout {
|
|
spacing: Theme.gap;
|
|
alignment: start;
|
|
|
|
Rectangle {
|
|
width: 18px;
|
|
height: 18px;
|
|
y: (parent.height - self.height) / 2;
|
|
border-radius: Theme.radius-sm;
|
|
border-width: 1px;
|
|
border-color: root.checked ? Theme.active : Theme.rule;
|
|
background: root.checked ? Theme.active : transparent;
|
|
|
|
Icon {
|
|
name: "check";
|
|
// Dark on the fill: `active` is near-white, and the white tick
|
|
// this carried against a saturated accent is invisible on it.
|
|
ink: Theme.ground;
|
|
size: 11px;
|
|
visible: root.checked;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
}
|
|
|
|
VerticalLayout {
|
|
spacing: 1px;
|
|
alignment: center;
|
|
|
|
Label { text: root.label; body: true; emphasised: touch.has-hover; }
|
|
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- one choice out of several ------------------------------------------
|
|
|
|
// One term in a `Segmented`.
|
|
//
|
|
// Not `FilterChip`, which widgets.slint keeps for the library's rating filter:
|
|
// that one carries a count and has on-off semantics, where this is
|
|
// single-selection over a fixed list. They look alike and behave differently,
|
|
// which is exactly the case for two components rather than one with a flag.
|
|
export component ChoiceChip inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> selected: false;
|
|
in property <bool> enabled: true;
|
|
|
|
callback clicked();
|
|
|
|
height: Theme.control-height;
|
|
// Wide enough that a one-word label is still a comfortable target, which
|
|
// is what `control-min-width` exists for — but chips sit several to a row,
|
|
// so they take their own narrower floor rather than the button's.
|
|
min-width: 64px;
|
|
border-radius: Theme.radius;
|
|
border-width: 1px;
|
|
border-color: root.selected ? Theme.active : Theme.rule;
|
|
background: !root.enabled ? transparent
|
|
: (root.selected ? Theme.active
|
|
: (touch.pressed ? Theme.pressed
|
|
: (touch.has-hover ? Theme.hover : transparent)));
|
|
opacity: root.enabled ? 1.0 : 0.4;
|
|
|
|
touch := TouchArea {
|
|
height: max(parent.height, Theme.touch-target);
|
|
y: (parent.height - self.height) / 2;
|
|
enabled: root.enabled;
|
|
mouse-cursor: pointer;
|
|
clicked => { root.clicked(); }
|
|
}
|
|
|
|
Text {
|
|
text: root.label;
|
|
// Dark on the fill: `active` is near-white and ink on it is invisible.
|
|
color: root.selected ? Theme.ground : Theme.ink;
|
|
font-size: Theme.text;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
}
|
|
|
|
// --- the labelled-row scaffold ------------------------------------------
|
|
|
|
// A control's name, with an aside about it on the same line.
|
|
//
|
|
// Written out three times inside settings.slint before this — once each in the
|
|
// choice row, the entry row and the switch — as a body `Label` beside a
|
|
// right-aligned elided `Caption`. Three copies of a two-element layout is how
|
|
// the hint ends up aligned differently from one row to the next.
|
|
//
|
|
// The name sits *above* its control rather than beside it: the control rows
|
|
// are wide, and a left-hand label column would either crush them or leave the
|
|
// page half empty at narrow widths. Stacked, every row uses the full width at
|
|
// any window size (FR-UI-1).
|
|
export component FieldRow inherits HorizontalLayout {
|
|
in property <string> label;
|
|
in property <string> hint;
|
|
|
|
spacing: Theme.gap;
|
|
|
|
Label { text: root.label; body: true; }
|
|
|
|
Caption {
|
|
text: root.hint;
|
|
horizontal-alignment: right;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
|
|
// TRACES: FR-UI-2
|
|
// A block of chips that wraps, laid out by arithmetic rather than by a layout.
|
|
//
|
|
// **Slint has no wrapping layout, and a `HorizontalLayout` of chips is a
|
|
// hazard in a narrow column.** Chips carry a comfortable minimum width, so a
|
|
// row of them reports that minimum times their count as the width it needs —
|
|
// and in the develop column, where every panel's declared width is `max`ed
|
|
// into one number, a single six-choice parameter silently sets the width of
|
|
// the whole application's sidebar. `film_sim`'s six film formats did exactly
|
|
// that: 414px of chips holding a column open that documents itself, in a
|
|
// dozen comments, as 280px wide.
|
|
//
|
|
// A `GridLayout` is not the alternative — a `for` inside one compiles and then
|
|
// fails at run time with `RepeatedItemTree::grid_layout_input_data() not
|
|
// implemented`, piling every chip into a single row. So the grid is index
|
|
// arithmetic inside a plain `Rectangle`: it costs the layout engine nothing,
|
|
// it wraps a seventh choice onto a third row by itself, and it declares a
|
|
// width that depends on the column count rather than on the choice count.
|
|
//
|
|
// **The chip width is fixed, not a share of the container's.** Dividing
|
|
// `self.width` looks right and clips: the develop column sets its width to
|
|
// `min(panel-max-width, panel-width)` with `clip: true`, so when the policy
|
|
// bites, `self.width` here is the width that was *asked for* and the visible
|
|
// column is narrower.
|
|
export component ChipGrid inherits Rectangle {
|
|
in property <[string]> options;
|
|
in property <int> selected: -1;
|
|
in property <bool> enabled: true;
|
|
/// How many chips to a row. Three is what fits the narrowest column the
|
|
/// application supports.
|
|
in property <int> columns: 3;
|
|
/// Comfortable for a one-word label at `Theme.text`, and three of them
|
|
/// plus their gaps fit a 280px column.
|
|
in property <length> chip-width: 88px;
|
|
|
|
callback picked(int);
|
|
|
|
property <int> rows: max(1, ceil(root.options.length / max(1, root.columns)));
|
|
property <length> pitch: Theme.control-height + Theme.gap-sm;
|
|
|
|
// Stated so a column measuring itself counts this block, and counts it at
|
|
// the width it will actually draw at rather than at the width a single row
|
|
// of every choice would need.
|
|
min-width: root.columns * root.chip-width + (root.columns - 1) * Theme.gap-sm;
|
|
height: root.rows * root.pitch - Theme.gap-sm;
|
|
|
|
for option[i] in root.options: ChoiceChip {
|
|
x: mod(i, root.columns) * (root.chip-width + Theme.gap-sm);
|
|
y: floor(i / root.columns) * root.pitch;
|
|
width: root.chip-width;
|
|
height: Theme.control-height;
|
|
label: option;
|
|
selected: i == root.selected;
|
|
enabled: root.enabled;
|
|
clicked => { root.picked(i); }
|
|
}
|
|
}
|
|
|
|
// A labelled row of chips: one choice out of a short list.
|
|
//
|
|
// Not a dropdown. Every choice set on the settings page is short and the
|
|
// options are worth reading side by side — a photographer picking an output
|
|
// colour space benefits from seeing that ProPhoto exists next to sRGB, which a
|
|
// collapsed menu hides behind a click. ARCH §4.3 names the dropdown as the
|
|
// *pointer* presentation of the same `Enum`; this is the segmented one, and
|
|
// when the modality switch lands it will sit beside a dropdown rather than be
|
|
// replaced by one.
|
|
export component Segmented inherits VerticalLayout {
|
|
in property <string> label;
|
|
in property <string> hint;
|
|
in property <[string]> options;
|
|
in property <int> selected: 0;
|
|
in property <bool> enabled: true;
|
|
/// Wrap onto this many chips per row instead of laying them all in one.
|
|
///
|
|
/// Zero — one row, however many chips — is right for the settings page,
|
|
/// which is a full-width page and where reading the alternatives side by
|
|
/// side is the whole argument for chips over a dropdown. The develop
|
|
/// column is the opposite case: it is narrow, its width is the largest any
|
|
/// panel asks for, and a row of six chips there sets the width of the
|
|
/// sidebar for every other panel. See `ChipGrid`.
|
|
in property <int> columns: 0;
|
|
|
|
callback picked(int);
|
|
|
|
spacing: 4px;
|
|
|
|
FieldRow { label: root.label; hint: root.hint; }
|
|
|
|
if root.columns <= 0: HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
alignment: start;
|
|
|
|
for option[i] in root.options: ChoiceChip {
|
|
label: option;
|
|
selected: i == root.selected;
|
|
enabled: root.enabled;
|
|
clicked => { root.picked(i); }
|
|
}
|
|
}
|
|
|
|
if root.columns > 0: ChipGrid {
|
|
options: root.options;
|
|
selected: root.selected;
|
|
enabled: root.enabled;
|
|
columns: root.columns;
|
|
picked(i) => { root.picked(i); }
|
|
}
|
|
}
|
|
|
|
// A text entry with its label above and an optional unit after it.
|
|
//
|
|
// `Field` is `touch-target` tall and stretches, which is right for a server URL
|
|
// on the launch screen and wrong for a filename template — so this constrains
|
|
// the width rather than restyling the field.
|
|
export component TextRow inherits VerticalLayout {
|
|
in property <string> label;
|
|
in property <string> hint;
|
|
in-out property <string> text;
|
|
in property <string> unit;
|
|
in property <string> placeholder;
|
|
in property <bool> enabled: true;
|
|
in property <length> field-width: 140px;
|
|
|
|
callback accepted(string);
|
|
|
|
spacing: 4px;
|
|
|
|
FieldRow { label: root.label; hint: root.hint; }
|
|
|
|
HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
alignment: start;
|
|
|
|
Rectangle {
|
|
width: root.field-width;
|
|
height: field.preferred-height;
|
|
opacity: root.enabled ? 1.0 : 0.4;
|
|
|
|
field := Field {
|
|
width: 100%;
|
|
text <=> root.text;
|
|
placeholder: root.placeholder;
|
|
// Committed on Enter *and* on losing focus. Enter alone loses
|
|
// an edit the moment the user clicks the next control, which
|
|
// on a page that saves continuously reads as the setting not
|
|
// having taken.
|
|
accepted(t) => { root.accepted(t); }
|
|
}
|
|
|
|
// `Field` reports focus but does not signal losing it, so the
|
|
// change is watched here.
|
|
property <bool> focused: field.has-focus;
|
|
changed focused => {
|
|
if (!self.focused) {
|
|
root.accepted(root.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: root.unit;
|
|
visible: root.unit != "";
|
|
vertical-alignment: center;
|
|
height: Theme.touch-target;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- sliders that carry their own readout -------------------------------
|
|
|
|
// A control with its name and current value on the line above it.
|
|
//
|
|
// The develop panel's shape, generalised: a parameter's name on the left, what
|
|
// it currently reads on the right, and the control itself beneath. The control
|
|
// arrives as `@children` rather than being built here, so the same header
|
|
// serves a track today and whatever else wants one later without this
|
|
// component learning what a track is.
|
|
//
|
|
// **`modified` drives both halves.** A value differing from its default is the
|
|
// single thing a photographer scans a panel for, and with hue gone from the
|
|
// palette the only signal left is luminance — so the label lights and the
|
|
// readout changes ink together, from one fact, rather than two call sites each
|
|
// deciding.
|
|
export component ControlRow inherits VerticalLayout {
|
|
in property <string> label;
|
|
/// Already formatted. Precision belongs to whoever owns the value — a
|
|
/// descriptor declares it — and a control that rounded on its own would
|
|
/// show the same parameter two ways in the same panel.
|
|
in property <string> readout;
|
|
in property <bool> modified: false;
|
|
|
|
spacing: 2px;
|
|
|
|
HorizontalLayout {
|
|
Label { text: root.label; emphasised: root.modified; }
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
Value {
|
|
text: root.readout;
|
|
modified: root.modified;
|
|
placeholder: !root.modified;
|
|
compact: true;
|
|
}
|
|
}
|
|
|
|
@children
|
|
}
|
|
|
|
// A slider and an editable number box for the same value.
|
|
//
|
|
// **The pointer presentation of a bounded scalar** (ARCH §4.3): the track for
|
|
// choosing a value by eye, the box for saying one exactly. Neither alone is
|
|
// enough — a track cannot express "exactly 90", and a bare number box makes
|
|
// the user guess what the range is until they exceed it.
|
|
//
|
|
// This is what the settings page's bounded numbers should have been. Export
|
|
// quality is 1-to-100 and was a free-text field, so the range was written in a
|
|
// hint and enforced nowhere, and `to-float()` turned a typo into zero.
|
|
//
|
|
// Distinct from `ControlRow` + `SliderTrack`, which is what the develop panel
|
|
// uses: there the readout is *not* editable, because that column is 280px wide
|
|
// and holds thirty-six of these in the colour mixer alone — a text box per row
|
|
// would be most of the width and a keyboard target nobody is aiming for. Two
|
|
// presentations of one idea, and which is right depends on how many are on
|
|
// screen at once.
|
|
export component SliderRow inherits VerticalLayout {
|
|
in property <string> label;
|
|
in property <string> hint;
|
|
in property <float> value;
|
|
in property <float> default-value;
|
|
in property <float> minimum;
|
|
in property <float> maximum;
|
|
in property <int> precision: 0;
|
|
in property <bool> enabled: true;
|
|
|
|
/// Fires once per completed gesture, not once per movement.
|
|
///
|
|
/// This row exists for settings-shaped values, whose handlers persist —
|
|
/// so it takes `SliderTrack`'s `committed` rather than its `changed` and
|
|
/// spares every call site the debounce. A caller that genuinely wants the
|
|
/// live stream, as the develop panel does, composes `ControlRow` with a
|
|
/// bare track instead.
|
|
callback changed(float);
|
|
callback reset();
|
|
|
|
spacing: 4px;
|
|
|
|
// What the controls draw while a drag is in flight.
|
|
//
|
|
// The committed value only arrives at the end of the gesture, so without
|
|
// this the handle would sit still under the finger for the whole drag and
|
|
// jump at release. Seeded and re-seeded imperatively rather than bound:
|
|
// Slint drops a binding on the first assignment, so a bound property would
|
|
// follow `value` until the first drag and never again.
|
|
property <float> live: root.value;
|
|
init => { root.live = root.value; }
|
|
changed value => { root.live = root.value; }
|
|
|
|
// A declared precision is a declared *step*, not merely a display format.
|
|
//
|
|
// Without this the track hands out the raw position under the finger, so a
|
|
// quality of 89.6 reads as "90" in the box — `NumberField` rounds for
|
|
// display — and arrives at a caller storing whole numbers as 89. The box
|
|
// and the stored value would disagree by one, visibly, on release. Snapping
|
|
// here makes the two the same number by construction.
|
|
property <float> step-factor: Math.pow(10, root.precision);
|
|
pure function quantise(v: float) -> float {
|
|
return Math.round(v * root.step-factor) / root.step-factor;
|
|
}
|
|
|
|
FieldRow { label: root.label; hint: root.hint; }
|
|
|
|
HorizontalLayout {
|
|
spacing: Theme.gap;
|
|
opacity: root.enabled ? 1.0 : 0.4;
|
|
|
|
SliderTrack {
|
|
horizontal-stretch: 1;
|
|
// Centred against the number box, which is a full touch target
|
|
// tall where the track is half of one.
|
|
y: (parent.height - self.height) / 2;
|
|
|
|
value: root.live;
|
|
default-value: root.default-value;
|
|
minimum: root.minimum;
|
|
maximum: root.maximum;
|
|
|
|
changed(v) => { root.live = root.quantise(v); }
|
|
committed(v) => { root.changed(root.quantise(v)); }
|
|
reset => { root.reset(); }
|
|
}
|
|
|
|
NumberField {
|
|
// Follows the drag, so the number and the handle never disagree.
|
|
value: root.live;
|
|
minimum: root.minimum;
|
|
maximum: root.maximum;
|
|
precision: root.precision;
|
|
enabled: root.enabled;
|
|
// A typed entry is already a completed gesture.
|
|
changed(v) => { root.changed(v); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- two-dimensional controls -------------------------------------------
|
|
|
|
// A tone curve editor: a square grid with draggable control points.
|
|
//
|
|
// The curve *line* is drawn from `samples`, which Rust evaluates with the same
|
|
// spline the shader uses. Reimplementing the interpolation here would mean two
|
|
// curves that could disagree — the drawn one and the applied one — which is the
|
|
// worst possible failure for a control whose whole job is to show you what it
|
|
// is doing.
|
|
//
|
|
// Takes `points` directly rather than the `ParamRow` it used to read, for the
|
|
// reason given in this file's preamble: a primitive that knows the develop
|
|
// panel's model can only ever be used by the develop panel.
|
|
export component CurveEditor inherits Rectangle {
|
|
/// Control point coordinates, x and y interleaved, each 0..1 with y up.
|
|
in property <[float]> points;
|
|
/// Polyline of the curve, y sampled at even x. 0..1, y up.
|
|
in property <[float]> samples;
|
|
/// Which point is being dragged, or -1.
|
|
in-out property <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 `SliderTrack`'s `engaged` —
|
|
/// and more acutely here, because a curve point is dragged *vertically*,
|
|
/// which is the Flickable's own axis and so is contested every time.
|
|
callback drag-changed(bool);
|
|
|
|
property <int> point-count: root.points.length / 2;
|
|
|
|
// Hover, not the drag: `active-point` is set on press, and the press is
|
|
// exactly what a Flickable withholds. Only the plot's grab targets count,
|
|
// so the rest of the plot still scrolls the panel.
|
|
property <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.points[idx * 2];
|
|
property <float> py: root.points[idx * 2 + 1];
|
|
|
|
visible: self.exists;
|
|
x: parent.width * self.px - 5px;
|
|
y: parent.height * (1.0 - self.py) - 5px;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 5px;
|
|
// Grown and tinted when grabbable, so it is obvious where the
|
|
// curve takes the gesture and where the panel scrolls instead.
|
|
property <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.points[idx * 2] - 14px;
|
|
y: self.dragging ? 0px
|
|
: parent.height * (1.0 - root.points[idx * 2 + 1]) - 14px;
|
|
width: self.dragging ? parent.width : 28px;
|
|
height: self.dragging ? parent.height : 28px;
|
|
visible: self.exists;
|
|
mouse-cursor: pointer;
|
|
|
|
// Highlights the marker, so it is visible where the curve takes
|
|
// the gesture and where the panel scrolls instead.
|
|
changed has-hover => {
|
|
if (self.has-hover) {
|
|
root.hovered-point = idx;
|
|
} else if (root.hovered-point == idx) {
|
|
root.hovered-point = -1;
|
|
}
|
|
}
|
|
|
|
pointer-event(ev) => {
|
|
if (ev.kind == PointerEventKind.down
|
|
&& ev.button == PointerEventButton.left) {
|
|
root.active-point = idx;
|
|
}
|
|
if (ev.kind == PointerEventKind.up
|
|
|| ev.kind == PointerEventKind.cancel) {
|
|
root.active-point = -1;
|
|
}
|
|
}
|
|
moved => {
|
|
if (self.dragging) {
|
|
// Coordinates are relative to this area, which is the whole
|
|
// plot while dragging — so no offset is needed.
|
|
root.point-moved(
|
|
idx,
|
|
clamp(self.mouse-x / parent.width, 0.0, 1.0),
|
|
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
|
|
}
|
|
}
|
|
double-clicked => { root.reset(); }
|
|
}
|
|
}
|
|
}
|