Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
This commit is contained in:
+202
-115
@@ -8,6 +8,7 @@
|
||||
// (FR-DEV-3c).
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { PanelHeading, Label, Value, Caption, Section } from "widgets.slint";
|
||||
|
||||
// One parameter, flattened for Slint's model system.
|
||||
//
|
||||
@@ -24,9 +25,21 @@ export struct ParamRow {
|
||||
op-label: string,
|
||||
param-label: string,
|
||||
|
||||
// True on the first parameter of each operation, so the panel can draw a
|
||||
// section heading without knowing what the sections are.
|
||||
starts-group: bool,
|
||||
// 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.
|
||||
@@ -62,26 +75,25 @@ component ParamSlider inherits Rectangle {
|
||||
spacing: 2px;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
Label {
|
||||
text: root.data.param-label;
|
||||
color: root.data.value != root.data.default-value
|
||||
? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
emphasised: root.data.value != root.data.default-value;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Text {
|
||||
Value {
|
||||
// Precision comes from the descriptor, so a control in stops
|
||||
// reads 1.25 while one in whole units reads 25.
|
||||
text: root.data.precision == 0
|
||||
? Math.round(root.data.value) + root.data.unit
|
||||
: (Math.round(root.data.value * 100) / 100) + root.data.unit;
|
||||
color: root.data.value != root.data.default-value
|
||||
? Theme.accent : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
// The one readout in the panel that has moved off its default
|
||||
// is what the eye is hunting for, and `modified` is the only
|
||||
// thing left to say it with once hue is gone.
|
||||
modified: root.data.value != root.data.default-value;
|
||||
placeholder: root.data.value == root.data.default-value;
|
||||
compact: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +135,10 @@ component ParamSlider inherits Rectangle {
|
||||
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
|
||||
y: (parent.height - 3px) / 2;
|
||||
height: 3px;
|
||||
background: Theme.accent;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -147,6 +162,16 @@ component ParamSlider inherits Rectangle {
|
||||
|
||||
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.
|
||||
property <bool> claimed: false;
|
||||
|
||||
function value-at(px: length) -> float {
|
||||
return clamp(
|
||||
root.data.minimum + (px / self.width) * self.span,
|
||||
@@ -155,15 +180,23 @@ component ParamSlider inherits Rectangle {
|
||||
}
|
||||
|
||||
moved => {
|
||||
// `moved` fires only while pressed, so this is the drag.
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
// `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) => {
|
||||
// Jump to the press position, so a click anywhere on the
|
||||
// track sets the value and a drag continues from there.
|
||||
if (ev.kind == PointerEventKind.down
|
||||
&& ev.button == PointerEventButton.left) {
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
if (ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel) {
|
||||
self.claimed = false;
|
||||
}
|
||||
// Right-click resets, alongside double-click.
|
||||
if (ev.kind == PointerEventKind.down
|
||||
@@ -171,6 +204,14 @@ component ParamSlider inherits Rectangle {
|
||||
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();
|
||||
}
|
||||
@@ -198,6 +239,10 @@ component CurveEditor inherits Rectangle {
|
||||
|
||||
property <int> point-count: root.data.points.length / 2;
|
||||
|
||||
// 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;
|
||||
@@ -232,7 +277,7 @@ component CurveEditor inherits Rectangle {
|
||||
// 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.accent;
|
||||
background: Theme.active;
|
||||
}
|
||||
|
||||
// Control points.
|
||||
@@ -247,61 +292,97 @@ component CurveEditor inherits Rectangle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: root.active-point == idx ? Theme.accent : Theme.ink;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Catches the release even when the pointer has left the grab
|
||||
// target, and resets on double-click.
|
||||
area := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// **One grab target per point, and nothing covering the rest.**
|
||||
//
|
||||
// Three constraints meet here, and only this arrangement satisfies
|
||||
// all of them:
|
||||
//
|
||||
// 1. The panel scrolls, and Slint cannot hand back a press once
|
||||
// taken — so an area spanning the plot would swallow every scroll
|
||||
// gesture beginning over the curve. Small targets leave the rest
|
||||
// of the plot free.
|
||||
// 2. `enabled: false` does not work as a gate: a disabled TouchArea
|
||||
// recognises *no* events at all, hover included, so it cannot
|
||||
// report where the pointer is in order to decide.
|
||||
// 3. A target positioned by its own point would slide out from under
|
||||
// the pointer on the first movement, stalling the drag. So while
|
||||
// a point is being dragged its target **freezes** at the press
|
||||
// position and grows to cover the plot, keeping the pointer
|
||||
// inside it however far the point travels.
|
||||
for idx in [0, 1, 2, 3, 4]: TouchArea {
|
||||
property <bool> exists: idx < root.point-count;
|
||||
property <bool> dragging: root.active-point == idx;
|
||||
|
||||
// Frozen and expanded while dragging; tracking the point
|
||||
// otherwise.
|
||||
x: self.dragging ? 0px
|
||||
: parent.width * root.data.points[idx * 2] - 14px;
|
||||
y: self.dragging ? 0px
|
||||
: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 14px;
|
||||
width: self.dragging ? parent.width : 28px;
|
||||
height: self.dragging ? parent.height : 28px;
|
||||
visible: self.exists;
|
||||
mouse-cursor: pointer;
|
||||
|
||||
// Highlights the marker, so it is visible where the curve takes
|
||||
// the gesture and where the panel scrolls instead.
|
||||
changed has-hover => {
|
||||
if (self.has-hover) {
|
||||
root.hovered-point = idx;
|
||||
} else if (root.hovered-point == idx) {
|
||||
root.hovered-point = -1;
|
||||
}
|
||||
}
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down
|
||||
&& ev.button == PointerEventButton.left) {
|
||||
root.active-point = idx;
|
||||
}
|
||||
if (ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel) {
|
||||
root.active-point = -1;
|
||||
}
|
||||
}
|
||||
moved => {
|
||||
if (root.active-point >= 0) {
|
||||
if (self.dragging) {
|
||||
// Coordinates are relative to this area, which is the
|
||||
// whole plot while dragging — so no offset is needed.
|
||||
root.point-moved(
|
||||
root.active-point,
|
||||
idx,
|
||||
clamp(self.mouse-x / parent.width, 0.0, 1.0),
|
||||
clamp(1.0 - self.mouse-y / parent.height, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.up) {
|
||||
root.active-point = -1;
|
||||
}
|
||||
}
|
||||
double-clicked => { root.reset(); }
|
||||
}
|
||||
|
||||
// One grab target per point, above the shared area so a press picks
|
||||
// the point under the pointer rather than the panel guessing.
|
||||
for idx in [0, 1, 2, 3, 4]: TouchArea {
|
||||
property <bool> exists: idx < root.point-count;
|
||||
x: parent.width * root.data.points[idx * 2] - 11px;
|
||||
y: parent.height * (1.0 - root.data.points[idx * 2 + 1]) - 11px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
enabled: self.exists;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
root.active-point = idx;
|
||||
}
|
||||
}
|
||||
moved => {
|
||||
if (root.active-point == idx) {
|
||||
root.point-moved(
|
||||
idx,
|
||||
clamp((self.x + self.mouse-x) / parent.width, 0.0, 1.0),
|
||||
clamp(1.0 - (self.y + self.mouse-y) / parent.height, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The panel: a heading per operation, a control per parameter.
|
||||
// The panel: a collapsible section per operation, a control per parameter.
|
||||
//
|
||||
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
|
||||
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
|
||||
// repeat over an integer — `for n in row.group-len` — so each group's heading
|
||||
// row renders its whole group by indexing back into `rows` from `group-head`,
|
||||
// and every other row renders nothing. That puts the group's controls genuinely
|
||||
// *inside* its `Section`, which is what makes collapse a matter of the section
|
||||
// clipping its own body rather than each row hiding itself.
|
||||
//
|
||||
// It also means collapse state is the `Section`'s own — one `expanded` per
|
||||
// repeated element, keyed by position and so by `op-index`, never by label.
|
||||
// Slint keeps that state across row-*data* updates, which is what lets a
|
||||
// collapsed section stay collapsed while a slider elsewhere is dragged: the
|
||||
// panel is re-fed on every drag event.
|
||||
export component AdjustPanel inherits Rectangle {
|
||||
in property <[ParamRow]> rows;
|
||||
in property <bool> enabled: true;
|
||||
@@ -311,6 +392,9 @@ export component AdjustPanel inherits Rectangle {
|
||||
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;
|
||||
@@ -321,34 +405,21 @@ export component AdjustPanel inherits Rectangle {
|
||||
alignment: start;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: "ADJUST";
|
||||
color: Theme.accent;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
PanelHeading { text: "ADJUST"; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
reset := TouchArea {
|
||||
width: 44px;
|
||||
height: 20px;
|
||||
clicked => { root.reset-all(); }
|
||||
Text {
|
||||
Label {
|
||||
text: "reset";
|
||||
color: reset.has-hover ? Theme.ink : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
emphasised: reset.has-hover;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !root.enabled: Text {
|
||||
text: "No image";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
}
|
||||
if !root.enabled: Caption { text: "No image"; }
|
||||
|
||||
if root.enabled: Flickable {
|
||||
viewport-height: content.preferred-height;
|
||||
@@ -357,48 +428,64 @@ export component AdjustPanel inherits Rectangle {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
// One iteration per row, but only a group's *first* row draws
|
||||
// anything — and it draws the whole group. Every other row
|
||||
// renders nothing at all.
|
||||
for row[i] in root.rows: VerticalLayout {
|
||||
spacing: 0px;
|
||||
|
||||
// Section heading, driven by the flag the core set — this
|
||||
// file never asks "which operation is this".
|
||||
if row.starts-group: VerticalLayout {
|
||||
Rectangle { height: Theme.gap; }
|
||||
Text {
|
||||
text: row.op-label;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
Rectangle { height: 2px; }
|
||||
}
|
||||
// `if` rather than a zero height: a hidden-but-present
|
||||
// section would still *build* its whole group, so every
|
||||
// control would exist once per row of its own group —
|
||||
// thirty-six live TouchAreas behind the colour mixer's
|
||||
// twelve visible ones. The conditional builds nothing.
|
||||
if row.group-head == i: Section {
|
||||
title: row.op-label;
|
||||
modified: row.group-modified;
|
||||
|
||||
if row.kind == "scalar": ParamSlider {
|
||||
data: row;
|
||||
changed(v) => {
|
||||
root.param-changed(row.op-index, row.param-index, v);
|
||||
}
|
||||
reset => {
|
||||
root.param-reset(row.op-index, row.param-index);
|
||||
}
|
||||
}
|
||||
// The section's reset. Placed here rather than in
|
||||
// `Section` itself because resetting is what *this*
|
||||
// panel's sections do; a section in another panel may
|
||||
// have nothing to reset.
|
||||
op-reset => { root.op-reset(row.op-index); }
|
||||
|
||||
if row.kind == "curve": CurveEditor {
|
||||
data: row;
|
||||
samples: root.curve-samples;
|
||||
// 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(
|
||||
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);
|
||||
// The group's own rows, addressed by offset from its
|
||||
// head. `root.rows[...]` rather than the loop's `row`:
|
||||
// this repeats over a count, so `n` is a number and
|
||||
// the row has to be fetched.
|
||||
for n in row.group-len: VerticalLayout {
|
||||
property <ParamRow> entry: root.rows[row.group-head + n];
|
||||
|
||||
spacing: 0px;
|
||||
|
||||
if entry.kind == "scalar": ParamSlider {
|
||||
data: entry;
|
||||
changed(v) => {
|
||||
root.param-changed(
|
||||
entry.op-index, entry.param-index, v);
|
||||
}
|
||||
reset => {
|
||||
root.param-reset(entry.op-index, entry.param-index);
|
||||
}
|
||||
}
|
||||
|
||||
if entry.kind == "curve": CurveEditor {
|
||||
data: entry;
|
||||
samples: root.curve-samples;
|
||||
// A point carries two parameters, so the
|
||||
// parameter index is the row's base plus the
|
||||
// point's offset. This component still knows
|
||||
// nothing about which operation it belongs to.
|
||||
point-moved(point, x, y) => {
|
||||
root.param-changed(
|
||||
entry.op-index, entry.param-index + point * 2, x);
|
||||
root.param-changed(
|
||||
entry.op-index, entry.param-index + point * 2 + 1, y);
|
||||
}
|
||||
reset => {
|
||||
root.curve-reset(entry.op-index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user