dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
807 lines
33 KiB
Plaintext
807 lines
33 KiB
Plaintext
// Generic adjustment controls, generated from pipeline capabilities.
|
|
//
|
|
// **Nothing here names an operation.** There is no "exposure slider" and no
|
|
// "saturation section" — the panel walks a model the core supplies and
|
|
// instantiates one control per entry, choosing the control from the
|
|
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
|
|
// the pipeline makes it appear here with no change to this file
|
|
// (FR-DEV-3c).
|
|
|
|
import { Theme } from "theme.slint";
|
|
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
|
|
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.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" | "enum" | "curve"
|
|
|
|
// A run of rows inside a group, for an operation whose parameters form a
|
|
// grid rather than a list.
|
|
//
|
|
// The colour mixer is twelve hue bands times three channels, and as a flat
|
|
// list it read as "Hue / Saturation / Luminance" twelve times over with
|
|
// nothing saying which band any row belonged to. Rust stacks the rows so
|
|
// each channel's twelve are together and marks the first of each run; this
|
|
// file names the run and lets the swatch identify the row.
|
|
//
|
|
// `facet-label` is the run's name and is empty on an ordinary parameter,
|
|
// which is every operation but the mixer.
|
|
facet-label: string,
|
|
starts-facet: bool,
|
|
|
|
// Where this row's subject sits on the hue wheel, in degrees, or -1 for a
|
|
// row whose subject is not a colour.
|
|
//
|
|
// A sentinel because a Slint struct cannot carry an optional, and -1
|
|
// rather than any in-range value because 0° is red — a real band, and the
|
|
// first one.
|
|
swatch-hue: float,
|
|
|
|
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],
|
|
|
|
// Enum rows only: the variant names, in index order.
|
|
//
|
|
// Resolved in Rust against the UI's catalogue, like every other label
|
|
// here — the core publishes localisation keys and never a display string.
|
|
// `value` on such a row is the chosen index, which is why an enum needs no
|
|
// separate selection field.
|
|
choices: [string],
|
|
}
|
|
|
|
// 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%;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A parameter's value, at the precision its descriptor declares.
|
|
//
|
|
// A global rather than the same ternary written into every control that shows
|
|
// one. There are two such controls now and the rule belongs to neither of
|
|
// them: precision comes from the descriptor, so a control in stops reads 1.25
|
|
// while one in whole units reads 25, and a copy that fell behind would show
|
|
// the same parameter two ways in the same panel. `SliderTrack`'s preamble is
|
|
// the longer version of this argument.
|
|
global Readout {
|
|
public pure function of(data: ParamRow) -> string {
|
|
return data.precision == 0
|
|
? Math.round(data.value) + data.unit
|
|
: (Math.round(data.value * 100) / 100) + data.unit;
|
|
}
|
|
}
|
|
|
|
// One generated parameter: a label, a readout, and the track above.
|
|
//
|
|
// The readout is *not* editable, where the settings page's `SliderRow` pairs
|
|
// the same track with a number box. That is a considered difference rather than
|
|
// an inconsistency: this column is 280px wide and the colour mixer alone puts
|
|
// thirty-six of these in it, so a text box per row would be most of the width
|
|
// and a keyboard target nobody is aiming for. The number is still reachable —
|
|
// the track resets on double-click and right-click.
|
|
component ParamSlider inherits Rectangle {
|
|
in property <ParamRow> data;
|
|
callback changed(float);
|
|
callback reset();
|
|
/// Forwarded from the track, for the panel's Flickable.
|
|
callback drag-changed(bool);
|
|
|
|
height: 46px;
|
|
|
|
ControlRow {
|
|
label: root.data.param-label;
|
|
readout: Readout.of(root.data);
|
|
// 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;
|
|
|
|
SliderTrack {
|
|
value: root.data.value;
|
|
default-value: root.data.default-value;
|
|
minimum: root.data.minimum;
|
|
maximum: root.data.maximum;
|
|
|
|
changed(v) => { root.changed(v); }
|
|
reset => { root.reset(); }
|
|
engaged-changed(on) => { root.drag-changed(on); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// The name of a run of rows inside a group: the mixer's Hue, Saturation and
|
|
// Luminance.
|
|
//
|
|
// A `Label` rather than a third `PanelHeading`. Two levels of
|
|
// caps-with-tracking already stack above it — the panel's own `ADJUST` and the
|
|
// operation's name — and a third in the same treatment would read as their
|
|
// peer instead of as something *inside* the operation. Sentence case at the
|
|
// same size says "a part of the group above" without another size or colour.
|
|
component FacetHeading inherits Rectangle {
|
|
in property <string> title;
|
|
|
|
height: Theme.control-height;
|
|
|
|
HorizontalLayout {
|
|
padding-left: Theme.gap-sm;
|
|
padding-top: Theme.gap-sm;
|
|
|
|
Label { text: root.title; }
|
|
}
|
|
}
|
|
|
|
// One faceted parameter: a swatch, a track and a readout, on a single line.
|
|
//
|
|
// **The swatch is the label.** Twelve of these sit under a heading that
|
|
// already names the channel, so the only thing a row has left to say is which
|
|
// band it edits — and a 12px square says it in a fraction of the width the
|
|
// word would take. That is what makes twelve rows fit where four did: the
|
|
// mixer is thirty-six controls, and at `ParamSlider`'s two-line 46px it was
|
|
// most of a screen of scrolling with the band name absent from every row of it
|
|
// anyway.
|
|
//
|
|
// **The name is not thrown away, it moves.** It is the row's accessible label,
|
|
// so a screen reader says "Orange" where the eye reads the colour, and the
|
|
// catalogue in `labels.rs` is where the mapping is written down for anyone who
|
|
// cannot separate two squares by eye. A row identified by colour *alone*
|
|
// would be a control some photographers could not use, which is why the
|
|
// spoken name is part of the design and not an afterthought.
|
|
component SwatchSlider inherits Rectangle {
|
|
in property <ParamRow> data;
|
|
callback changed(float);
|
|
callback reset();
|
|
/// Forwarded from the track, for the panel's Flickable.
|
|
callback drag-changed(bool);
|
|
|
|
// The track's own height plus a hairline of air. Denser than a
|
|
// `ParamSlider` because the label line it would need is gone, not because
|
|
// the touch target shrank — `SliderTrack` still owns a full-width hit area
|
|
// and the gestures behind it (FR-UI-3).
|
|
height: Theme.touch-target / 2 + 4px;
|
|
|
|
accessible-role: slider;
|
|
accessible-label: root.data.param-label;
|
|
accessible-value: Readout.of(root.data);
|
|
accessible-value-minimum: root.data.minimum;
|
|
accessible-value-maximum: root.data.maximum;
|
|
|
|
HorizontalLayout {
|
|
padding-left: Theme.gap-sm;
|
|
spacing: Theme.gap-sm;
|
|
|
|
Swatch {
|
|
hue: root.data.swatch-hue;
|
|
// Centred against the track rather than the row, which a layout
|
|
// would do for a stretching child and cannot do for a fixed one.
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
|
|
SliderTrack {
|
|
horizontal-stretch: 1;
|
|
|
|
value: root.data.value;
|
|
default-value: root.data.default-value;
|
|
minimum: root.data.minimum;
|
|
maximum: root.data.maximum;
|
|
|
|
changed(v) => { root.changed(v); }
|
|
reset => { root.reset(); }
|
|
engaged-changed(on) => { root.drag-changed(on); }
|
|
}
|
|
|
|
Value {
|
|
text: Readout.of(root.data);
|
|
modified: root.data.value != root.data.default-value;
|
|
placeholder: root.data.value == root.data.default-value;
|
|
compact: true;
|
|
// Fixed and right-aligned: a readout sized to its own text would
|
|
// pull the track's end left and right as the number changed, and
|
|
// twelve tracks that each ended somewhere different would be
|
|
// impossible to compare down the column.
|
|
width: 30px;
|
|
horizontal-alignment: right;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A slider the interface names itself, rather than one generated from a row.
|
|
//
|
|
// The straighten angle is reached through the session's own accessor, not
|
|
// through a row index, so there is no `ParamRow` to feed it. Only the labels
|
|
// and the source of the numbers differ — the track is the same component, and
|
|
// so is every gesture it recognises.
|
|
component PlainSlider inherits Rectangle {
|
|
in property <string> label;
|
|
in property <float> value;
|
|
in property <float> default-value: 0.0;
|
|
in property <float> minimum: -1.0;
|
|
in property <float> maximum: 1.0;
|
|
in property <string> unit;
|
|
|
|
callback changed(float);
|
|
callback reset();
|
|
callback drag-changed(bool);
|
|
|
|
height: 46px;
|
|
|
|
ControlRow {
|
|
label: root.label;
|
|
readout: (Math.round(root.value * 10) / 10) + root.unit;
|
|
modified: root.value != root.default-value;
|
|
|
|
SliderTrack {
|
|
value: root.value;
|
|
default-value: root.default-value;
|
|
minimum: root.minimum;
|
|
maximum: root.maximum;
|
|
|
|
changed(v) => { root.changed(v); }
|
|
reset => { root.reset(); }
|
|
engaged-changed(on) => { root.drag-changed(on); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// **The control registry: one row in, one control out.**
|
|
//
|
|
// Slint cannot instantiate a component from a runtime string, so mapping a
|
|
// declared kind to a control is necessarily a chain of `if`s. The thing worth
|
|
// insisting on is that there is exactly *one* such chain. There were two — the
|
|
// panel draws a lone parameter bare and a group under a heading, and each
|
|
// branch wrote out its own list of kinds — so `enum` would have had to be added
|
|
// in both, and a kind added to only one would appear or vanish depending on how
|
|
// many parameters its operation happened to declare.
|
|
//
|
|
// Everything below routes back through `param-changed` by index. This component
|
|
// knows a curve point spans two parameters and a swatch names a hue band; it
|
|
// knows nothing about which operation it is drawing, which is the property that
|
|
// makes it a registry rather than a panel.
|
|
component ParamControl inherits Rectangle {
|
|
in property <ParamRow> data;
|
|
/// Curve rows only; ignored by every other kind.
|
|
in property <[float]> curve-samples;
|
|
|
|
callback param-changed(int, int, float);
|
|
callback param-reset(int, int);
|
|
callback curve-reset(int);
|
|
callback drag-changed(bool);
|
|
|
|
height: layout.preferred-height;
|
|
|
|
layout := VerticalLayout {
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// A row whose subject is a colour is identified by that colour; every
|
|
// other scalar keeps its name. The two differ only in what stands in
|
|
// for the label — the track, the gestures and the routing are the same
|
|
// underneath.
|
|
if root.data.kind == "scalar" && root.data.swatch-hue >= 0: SwatchSlider {
|
|
data: root.data;
|
|
drag-changed(on) => { root.drag-changed(on); }
|
|
changed(v) => {
|
|
root.param-changed(root.data.op-index, root.data.param-index, v);
|
|
}
|
|
reset => {
|
|
root.param-reset(root.data.op-index, root.data.param-index);
|
|
}
|
|
}
|
|
|
|
if root.data.kind == "scalar" && root.data.swatch-hue < 0: ParamSlider {
|
|
data: root.data;
|
|
drag-changed(on) => { root.drag-changed(on); }
|
|
changed(v) => {
|
|
root.param-changed(root.data.op-index, root.data.param-index, v);
|
|
}
|
|
reset => {
|
|
root.param-reset(root.data.op-index, root.data.param-index);
|
|
}
|
|
}
|
|
|
|
// A fixed list of alternatives. The value *is* the index, so picking
|
|
// one is an ordinary parameter change and needs no separate route.
|
|
//
|
|
// Chips rather than a dropdown for the same reason the settings page
|
|
// uses them: these lists are short, and a collapsed menu hides the
|
|
// alternatives behind a click. ARCH §4.3 names the dropdown as the
|
|
// pointer presentation of the same kind, so this is where that choice
|
|
// will be made when the modality switch lands.
|
|
if root.data.kind == "enum": Segmented {
|
|
label: root.data.param-label;
|
|
options: root.data.choices;
|
|
selected: Math.round(root.data.value);
|
|
picked(i) => {
|
|
root.param-changed(root.data.op-index, root.data.param-index, i);
|
|
}
|
|
}
|
|
|
|
if root.data.kind == "curve": CurveEditor {
|
|
points: root.data.points;
|
|
samples: root.curve-samples;
|
|
drag-changed(on) => { root.drag-changed(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(
|
|
root.data.op-index, root.data.param-index + point * 2, x);
|
|
root.param-changed(
|
|
root.data.op-index, root.data.param-index + point * 2 + 1, y);
|
|
}
|
|
reset => { root.curve-reset(root.data.op-index); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Crop, rotation, flips and straightening — the framing controls.
|
|
//
|
|
// **Why this is hand-built when the rest of the panel is generated.** The
|
|
// generic path renders one slider per parameter, which for framing means eight
|
|
// of them: four crop edges the user would have to type coordinates into, and a
|
|
// "Rotate" slider running 0..3. Every one of those is a worse control than the
|
|
// gesture it stands for — a crop is dragged on the photograph, and a quarter
|
|
// turn is a button. So framing is presented rather than generated, and the
|
|
// generic panel drops it (see `AdjustPanel.skip-op`).
|
|
//
|
|
// This does not weaken ARCH §4.3: nothing here reads a parameter *value* out
|
|
// of a descriptor or routes by index. It calls named session actions, which is
|
|
// what a bespoke widget for a known stage is entitled to do.
|
|
export component GeometryPanel inherits Rectangle {
|
|
in property <bool> enabled: true;
|
|
/// Whether the crop overlay is up. The button is a toggle, not an action:
|
|
/// crop mode is sustained state, and the canvas looks different while it
|
|
/// is on.
|
|
in property <bool> crop-mode: false;
|
|
in property <float> angle: 0.0;
|
|
in property <float> max-straighten: 45.0;
|
|
in property <bool> flip-h: false;
|
|
in property <bool> flip-v: false;
|
|
/// Any of crop, angle, rotation or flips differs from neutral.
|
|
in property <bool> modified: false;
|
|
|
|
callback crop-toggled(bool);
|
|
callback rotate(int);
|
|
callback flip-h-toggled();
|
|
callback flip-v-toggled();
|
|
callback angle-changed(float);
|
|
callback angle-reset();
|
|
callback reset();
|
|
|
|
height: layout.preferred-height;
|
|
|
|
layout := VerticalLayout {
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// A heading, not a collapsible.
|
|
//
|
|
// These controls are the reason the column is open; folding them away
|
|
// behind a triangle put the sidebar's own contents one tap further
|
|
// from the photograph and gave every group a lid that had to be
|
|
// learned. `GroupHeading` keeps what the section was actually for —
|
|
// naming the group, flagging that it holds an edit, and offering the
|
|
// reset — without the hiding.
|
|
GroupHeading {
|
|
title: "GEOMETRY";
|
|
modified: root.modified;
|
|
has-reset: root.modified;
|
|
reset => { root.reset(); }
|
|
}
|
|
|
|
VerticalLayout {
|
|
spacing: Theme.gap-sm;
|
|
padding-bottom: Theme.gap-sm;
|
|
|
|
// Crop first: it is the framing decision the others serve.
|
|
Button {
|
|
text: root.crop-mode ? "Done Cropping" : "Crop";
|
|
active: root.crop-mode;
|
|
enabled: root.enabled;
|
|
clicked => { root.crop-toggled(!root.crop-mode); }
|
|
}
|
|
|
|
// Rotation and flips. Icons 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 {
|
|
icon: "rotate-ccw";
|
|
enabled: root.enabled;
|
|
clicked => { root.rotate(-1); }
|
|
}
|
|
IconButton {
|
|
icon: "rotate-cw";
|
|
enabled: root.enabled;
|
|
clicked => { root.rotate(1); }
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
IconButton {
|
|
icon: "flip-h";
|
|
active: root.flip-h;
|
|
enabled: root.enabled;
|
|
clicked => { root.flip-h-toggled(); }
|
|
}
|
|
IconButton {
|
|
icon: "flip-v";
|
|
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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The panel: a heading per multi-parameter operation, a control per parameter.
|
|
//
|
|
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
|
|
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
|
|
// repeat over an integer — `for n in row.group-len` — so each group's heading
|
|
// row renders its whole group by indexing back into `rows` from `group-head`,
|
|
// and every other row renders nothing.
|
|
//
|
|
// **Nothing here collapses.** Every group was a `Section` with a disclosure
|
|
// triangle until it became clear what that cost on a tablet: five of the
|
|
// pipeline's operations carry one parameter, so the lid was most of the row,
|
|
// and a control behind a lid is one the user does not know the pipeline has.
|
|
// The column is closed as a whole from the status strip instead, which is the
|
|
// control that was actually wanted.
|
|
// TRACES: FR-DEV-6
|
|
// Copying this photograph's settings, and pasting settings onto it.
|
|
//
|
|
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
|
|
// a tablet where there is no modifier key to hold and no menu bar to hang the
|
|
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
|
|
// accelerator for a control that is on screen either way, which is what keeps
|
|
// the feature discoverable on both platforms.
|
|
//
|
|
// The paste button carries what would be pasted rather than the bare word.
|
|
// "Paste" alone asks the user to remember what they copied and, crucially,
|
|
// whether the crop is coming with it — a question the label answers by
|
|
// naming the count the *current* scope would apply.
|
|
export component TransferPanel inherits VerticalLayout {
|
|
in property <bool> enabled: true;
|
|
/// Whether anything has been copied yet. Distinct from the clipboard
|
|
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
|
|
/// since it clears the target.
|
|
in property <bool> armed: false;
|
|
/// What a paste would apply — "3 adjustments", or "Neutral".
|
|
in property <string> summary;
|
|
/// Whether the clipboard holds framing the current scope is dropping.
|
|
/// Only then is it worth saying anything about the crop.
|
|
in property <bool> framing-withheld: false;
|
|
|
|
callback copy();
|
|
callback paste();
|
|
|
|
padding: Theme.gap;
|
|
spacing: Theme.gap-sm;
|
|
|
|
HorizontalLayout {
|
|
PanelHeading { text: "SETTINGS"; }
|
|
Rectangle { horizontal-stretch: 1; }
|
|
}
|
|
|
|
HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
|
|
Button {
|
|
text: "Copy";
|
|
enabled: root.enabled;
|
|
horizontal-stretch: 1;
|
|
clicked => { root.copy(); }
|
|
}
|
|
|
|
Button {
|
|
text: "Paste";
|
|
// Enabled on `armed` rather than on the summary being non-empty,
|
|
// so pasting a neutral copy — which clears this image — stays
|
|
// available. Still needs an image to paste *onto*.
|
|
enabled: root.enabled && root.armed;
|
|
horizontal-stretch: 1;
|
|
clicked => { root.paste(); }
|
|
}
|
|
}
|
|
|
|
if root.armed: Caption {
|
|
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// **The scroll gutter.**
|
|
//
|
|
// A strip down the right-hand edge that no control reaches, so
|
|
// there is always somewhere to put a thumb that means "scroll"
|
|
// and nothing else.
|
|
//
|
|
// It exists because of the arbitration in `SliderTrack`: a
|
|
// track stands the Flickable down as soon as a finger touches
|
|
// it, which is what makes dragging a slider reliable, and the
|
|
// cost is that the track can no longer be used to scroll past.
|
|
// The rows either side of a track were the only remaining
|
|
// purchase, and on a panel that is mostly tracks that came to
|
|
// aiming at a 20px band between controls. Reserving the space
|
|
// outright is the honest version of what was left to chance.
|
|
//
|
|
// Padding rather than a spacer element, and that is what makes
|
|
// it work: the strip is inside the Flickable but no child is
|
|
// laid out into it, so nothing puts a TouchArea over it. A
|
|
// press there reaches the Flickable directly, with no
|
|
// arbitration to lose.
|
|
//
|
|
// A full touch target wide (FR-UI-3), because a gutter too
|
|
// narrow to hit confidently is the problem it was added to fix.
|
|
padding-right: Theme.touch-target;
|
|
|
|
// One iteration per row, but only a group's *first* row draws
|
|
// anything — and it draws the whole group. Every other row
|
|
// renders nothing at all.
|
|
for row[i] in root.rows: VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// **A group of one is not a group.**
|
|
//
|
|
// Five of the pipeline's operations carry a single
|
|
// parameter — exposure, contrast, saturation, vibrance,
|
|
// brilliance — and wrapping each in a Section produced a
|
|
// collapsible heading, a disclosure triangle, a modified
|
|
// dot and a hover reset around one slider, with the
|
|
// operation's name printed in caps directly above the same
|
|
// word as the slider's own label. Five times over, that is
|
|
// a column that reads as chrome with controls hidden in it.
|
|
//
|
|
// So a lone parameter is drawn bare. It loses the group
|
|
// reset, which cost nothing: the slider already resets on
|
|
// double-click and right-click, and the section's reset was
|
|
// a hover-only affordance no finger could reach anyway.
|
|
//
|
|
// `row` is the entry here — the group's head is its only
|
|
// member — so there is nothing to index back into.
|
|
if row.group-head == i && row.group-len == 1: ParamControl {
|
|
data: row;
|
|
curve-samples: root.curve-samples;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
param-changed(op, param, v) => {
|
|
root.param-changed(op, param, v);
|
|
}
|
|
param-reset(op, param) => { root.param-reset(op, param); }
|
|
curve-reset(op) => { root.curve-reset(op); }
|
|
}
|
|
|
|
// `if` rather than a zero height: a hidden-but-present
|
|
// section would still *build* its whole group, so every
|
|
// control would exist once per row of its own group —
|
|
// thirty-six live TouchAreas behind the colour mixer's
|
|
// twelve visible ones. The conditional builds nothing.
|
|
if row.group-head == i && row.group-len > 1: VerticalLayout {
|
|
spacing: 0px;
|
|
padding-top: Theme.gap-sm;
|
|
|
|
// A heading rather than a lid. Several of these groups
|
|
// are two sliders; hiding two sliders behind a triangle
|
|
// costs more than it saves, and a control the user
|
|
// cannot see is one they do not know the pipeline has.
|
|
GroupHeading {
|
|
title: row.op-label;
|
|
modified: row.group-modified;
|
|
// Resetting is what *this* panel's groups do; the
|
|
// heading itself has no opinion about it.
|
|
reset => { root.op-reset(row.op-index); }
|
|
}
|
|
|
|
// The group's own rows, addressed by offset from its
|
|
// head. `root.rows[...]` rather than the loop's `row`:
|
|
// this repeats over a count, so `n` is a number and
|
|
// the row has to be fetched.
|
|
for n in row.group-len: VerticalLayout {
|
|
property <ParamRow> entry: root.rows[row.group-head + n];
|
|
|
|
spacing: 0px;
|
|
|
|
// A group whose parameters form a grid names each
|
|
// run once. Rust has already stacked the rows so a
|
|
// run is contiguous and marked its first row, for
|
|
// the same reason `group-head` exists: this model
|
|
// is flat and a `for` cannot nest inside a
|
|
// boundary discovered at runtime.
|
|
if entry.starts-facet: FacetHeading {
|
|
title: entry.facet-label;
|
|
}
|
|
|
|
ParamControl {
|
|
data: entry;
|
|
curve-samples: root.curve-samples;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
param-changed(op, param, v) => {
|
|
root.param-changed(op, param, v);
|
|
}
|
|
param-reset(op, param) => {
|
|
root.param-reset(op, param);
|
|
}
|
|
curve-reset(op) => { root.curve-reset(op); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: 1px;
|
|
background: Theme.rule;
|
|
}
|
|
}
|