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:
2026-08-09 21:11:38 +02:00
parent 7900184383
commit 8ad5c86ff9
20 changed files with 12192 additions and 489 deletions
+202 -115
View File
@@ -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);
}
}
}
}
}