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
+600
View File
@@ -0,0 +1,600 @@
// Shared chrome primitives and the style layer.
//
// Before this file every button was a Rectangle + TouchArea written out where
// it was needed, at 64×20, 110×28 and 88×28 with three near-identical
// hover/press treatments. Any consistency was coincidental. These are the
// pieces that make it deliberate; nothing here draws a colour literal.
//
// **The rule this file establishes.** Screen files consume components; raw
// `Theme.*` is for *composing* a component, not for styling a call site. A
// bare `Theme.ink-faint` or a font-size in `app.slint` means a component is
// missing, not that a screen needs an exception. `theme.slint` says what
// `surface` is; only this file says what a *panel heading* is — and until it
// did, four screens each re-derived one, which is exactly how a single accent
// colour reached forty call sites with no single place to change it.
import { Theme } from "theme.slint";
// A text button.
//
// **The drawn box and the hit target are separate.** FR-UI-3 asks for a 44pt
// minimum target under touch, but a 44px-tall button in a 44px-tall grid
// header leaves no room for the header, and the chrome would grow to meet a
// requirement that is about the *finger*, not the ink. So the rectangle is
// `Theme.control-height` and the TouchArea is grown to `Theme.touch-target`
// and centred over it, exactly as `FormatCheck` in launch.slint does. Callers
// laying these out horizontally get the compact size they expect; a thumb
// still gets 44px.
//
// The overhang is deliberately allowed to spill outside the parent's bounds.
// It only matters when two buttons sit within 8px vertically of each other,
// which no current layout does — buttons live in single rows.
export component Button inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
/// The one affirmative action in a group. At most one per group, or the
/// emphasis stops meaning anything (see the theme preamble).
in property <bool> primary: false;
/// Sustained state — a toggle that is currently on, not a press. The same
/// meaning [`IconButton`] gives it, so a labelled toggle and a glyph
/// toggle read alike.
in property <bool> active: false;
callback clicked();
height: Theme.control-height;
// A minimum rather than a fixed width: callers that set `width` or hand
// this to a stretching layout still win, and a long label is not clipped.
min-width: Theme.control-min-width;
horizontal-stretch: 0;
border-radius: Theme.radius;
border-width: root.primary ? 0px : 1px;
border-color: root.active ? Theme.active : Theme.rule;
// A primary button is filled rather than outlined, and with no hue left to
// fill it with the fill is near-white — so it moves the opposite way to a
// secondary button: hover *brightens* toward `active` and press sinks to
// `active-pressed`, where the neutral variant lifts from `surface-raised`.
background: root.primary
? (touch.pressed ? Theme.active-pressed
: (touch.has-hover ? Theme.active : Theme.active-dim))
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
// Disabled reads as "not now", not as a second kind of button — the shape
// stays and only the contrast drops.
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
// Explicit geometry: a TouchArea with none collapses to zero and only
// catches the events that happen to land on it.
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.clicked(); }
}
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Text {
text: root.text;
// Dark on the primary fill: that fill is now near-white, and the
// white label this carried when the fill was a saturated red is
// unreadable against it.
color: root.primary ? Theme.ground
: (root.active ? Theme.active : Theme.ink);
font-size: Theme.text-sm;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
}
// A square button carrying a single glyph.
//
// Square because it has no label to size against: a toolbar affordance whose
// width tracked its glyph would jitter as the glyph changed.
export component IconButton inherits Rectangle {
in property <string> glyph;
in property <bool> enabled: true;
/// Sustained state — a toggle that is currently on, not a press.
in property <bool> active: false;
callback clicked();
width: Theme.control-height;
height: Theme.control-height;
horizontal-stretch: 0;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.active ? Theme.active : Theme.rule;
background: touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised);
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
width: max(parent.width, Theme.touch-target);
height: max(parent.height, Theme.touch-target);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.clicked(); }
}
Text {
text: root.glyph;
color: root.active ? Theme.active : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// A toggle in a row of toggles: one term of a filter.
//
// Distinct from `Button` because it is *state*, not an action — it stays on
// after the click, and a row of them says what the grid is currently showing.
// A `Button` that happened to be styled differently would drift the moment
// either changed.
//
// **Active reads as filled, not merely outlined.** These sit in a row where
// several look alike, so an inactive-versus-active difference carried by a
// border alone is invisible at a glance across six chips. The active one
// inverts — near-white fill, dark text — which is the same treatment
// `Button.primary` uses for "this is the one".
//
// **The count is optional and never fabricated.** `-1` means "not known yet",
// which is different from zero: a filter with no images behind it should say
// `0` so the user knows narrowing to it will empty the grid, but one whose
// count has not been computed must not claim zero.
export component FilterChip inherits Rectangle {
in property <string> label;
in property <bool> active: false;
/// Images behind this term, or -1 where the count is not known.
in property <int> count: -1;
callback clicked();
height: Theme.control-height - 4px;
// A floor on a content-sized chip, expressed as one property: Slint rejects
// `width` and `min-width` together, and the floor is what keeps a chip
// labelled "3" from being a sliver too small to hit.
width: max(34px, row.preferred-width + 2 * Theme.gap-sm);
horizontal-stretch: 0;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.active ? Theme.active : Theme.rule;
background: root.active
? (touch.pressed ? Theme.active-pressed : Theme.active-dim)
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
row := HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: 4px;
Text {
text: root.label;
// Dark on the active fill, which is near-white — the same
// inversion `Button.primary` makes for the same reason.
color: root.active ? Theme.ground : Theme.ink;
font-size: Theme.text-sm;
font-weight: root.active ? 700 : 500;
vertical-alignment: center;
}
Text {
text: root.count >= 0 ? root.count : "";
// Dimmer than the label on both grounds: the count is supporting
// detail, and a chip whose number shouted louder than its name
// would read as a number with a caption.
color: root.active ? Theme.ground : Theme.ink-faint;
opacity: root.active ? 0.7 : 1.0;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
}
// The arrow beside a row that opens into something: a disclosure triangle on
// a section, an "into this folder" marker in the picker.
//
// **Fixed width, and that is the whole point.** The glyphs differ in advance
// width, so a row that sized to its own arrow would shift its label sideways
// as it opened and closed — the one movement that makes a static list look
// like it is being redrawn. Rotating a single glyph would need a transform on
// a Text; two characters render identically and cost nothing.
export component Disclosure inherits Text {
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
horizontal-alignment: center;
width: 14px;
}
// A collapsible group with a header that reports whether anything inside has
// been touched.
//
// `expanded` is in-out so a caller can key collapse state by something stable
// (an operation index, never a label) and drive it from outside; left alone it
// works standalone as a self-toggling disclosure.
//
// **Collapsing is fiddlier than it looks.** `@children` cannot appear inside
// a conditional element — Slint rejects it outright — so the body cannot be
// dropped from the tree with `if root.expanded`. And `visible: false` alone
// only hides the ink: the element keeps its layout slot, so a stack of
// collapsed sections would be a column of gaps.
//
// So the body is a plain Rectangle that is both hidden *and* clamped to zero
// height when collapsed, with `clip: true` so children taller than the clamp
// cannot paint outside it. The clamp reads `body-inner.preferred-height`,
// which is a *preferred* size — an input to layout, never a result of it —
// so `expanded` feeding the height does not loop back.
export component Section inherits Rectangle {
in property <string> title;
/// Anything inside differs from its default. The caller computes this —
/// the section cannot see into `@children`.
in property <bool> modified: false;
in-out property <bool> expanded: true;
/// Fired after `expanded` has already been flipped, for callers that
/// persist the state rather than letting this component own it.
callback toggled(bool);
/// Undo everything inside. The affordance only appears once `modified` is
/// true — a reset on an untouched group is a control that cannot do
/// anything, and a header carrying one permanently is a header that reads
/// as busy rather than as a name.
///
/// Sections whose contents have nothing to undo simply leave this
/// unconnected, and `has-reset` off.
callback op-reset();
/// Whether this section's contents can be reset at all.
in property <bool> has-reset: true;
background: transparent;
// Own height comes from the layout below, so a collapsed section shrinks
// to its header.
height: body.preferred-height;
body := VerticalLayout {
spacing: 0px;
alignment: start;
header := Rectangle {
height: Theme.control-height;
background: header-touch.pressed ? Theme.pressed
: (header-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
header-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => {
root.expanded = !root.expanded;
root.toggled(root.expanded);
}
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Disclosure { text: root.expanded ? "▾" : "▸"; }
Text {
text: root.title;
color: header-touch.has-hover ? Theme.ink : Theme.ink-dim;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 0.8px;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
// The modified dot: the one thing that survives collapsing,
// so a closed section still says whether it holds an edit.
Rectangle {
width: 6px;
height: 6px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
background: Theme.modified;
visible: root.modified;
}
// The group's reset. Shown only when there is something to
// undo *and* the pointer is on the header, so a panel at rest
// is a column of names rather than a column of buttons.
//
// It declares its width whether or not it is visible: a
// control that appeared on hover and *also* widened the row
// would shift the title sideways under the pointer, which
// reads as the panel flinching away from the cursor.
Rectangle {
width: 28px;
reset-touch := TouchArea {
// Sits after `header-touch` in the tree, so it takes
// the press first and the section does not toggle out
// from under a reset.
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-reset && root.modified;
mouse-cursor: pointer;
clicked => { root.op-reset(); }
}
Text {
text: "reset";
color: reset-touch.has-hover ? Theme.ink : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
horizontal-alignment: right;
width: 100%;
height: 100%;
visible: root.has-reset && root.modified
&& (header-touch.has-hover || reset-touch.has-hover);
}
}
}
}
Rectangle {
// Collapsed by height plus clip, deliberately *not* by `visible`:
// Slint treats a visibility-guarded element as conditional, and
// `@children` cannot appear inside one. Zero height with clipping
// hides the body just as completely.
height: root.expanded ? body-inner.preferred-height : 0px;
clip: true;
body-inner := VerticalLayout {
spacing: 0px;
alignment: start;
@children
}
}
}
}
// --- the style layer ---------------------------------------------------
//
// Text roles. Four components rather than one with a `role` enum, because a
// role is chosen once at the call site and never switched at runtime — an
// enum would buy nothing and cost a qualified name at every use.
// The name of a panel or a form section: `IMAGE`, `ADJUST`, `SERVER`.
//
// Caps-with-tracking rather than a larger size: these sit directly above the
// content they name, in a column only 280px wide, and a heading that grew the
// row would push the photograph over for the sake of a label. Tracking does
// the same separating work in the same height.
//
// `ink-faint` rather than the accent these all carried. A heading is a label,
// not a state — it is true whatever the panel is doing, so it has no business
// competing with the slider that *is* doing something. It is also read once
// and then skipped, which is what the faintest ink is for.
export component PanelHeading inherits Text {
/// A heading *inside* a panel that already has one — an operation group
/// under `ADJUST`. Tighter tracking, so the two levels are distinguishable
/// where they stack without either needing a second colour or size.
in property <bool> sub: false;
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: root.sub ? 0.8px : 1.2px;
vertical-alignment: center;
}
// The name of a thing whose value sits beside it: a parameter name, a form
// field's caption. Dimmer than its value on purpose — the label is constant
// and the value is what changed.
export component Label inherits Text {
/// Lit, for a label under the pointer or one whose value has moved off its
/// default. The caller supplies the condition; this only decides what
/// "lit" looks like.
in property <bool> emphasised: false;
/// Body size rather than the chrome's `text-sm`. For a label the user is
/// reading rather than scanning past — a row in a picker, a tick-box in a
/// form — where the panel is a page rather than an instrument.
in property <bool> body: false;
color: root.emphasised ? Theme.ink : Theme.ink-dim;
font-size: root.body ? Theme.text : Theme.text-sm;
vertical-alignment: center;
}
// A datum: a camera name, a file path, a slider's readout.
//
// **`modified` is the reason this is a component.** 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 gap has to
// be large and it has to be identical everywhere, or it stops reading as a
// signal at all and becomes texture. One definition, one gap.
export component Value inherits Text {
/// Differs from its default.
in property <bool> modified: false;
/// No value yet — a placeholder standing in for one, not a value that
/// happens to be empty.
in property <bool> placeholder: false;
/// The compact readout that sits on a control's own row, rather than a
/// datum on a line of its own.
in property <bool> compact: false;
color: root.modified ? Theme.modified
: (root.placeholder ? Theme.ink-faint : Theme.ink);
font-size: root.compact ? Theme.text-sm : Theme.text;
vertical-alignment: center;
}
// Supporting text: a hint under a field, a count beside a title, an empty
// state's second line. The faintest ink, because it is there for the reader
// who stopped to look and should not catch the eye of the one who did not.
export component Caption inherits Text {
/// A caution. The one place hue survives in the chrome — a warning is a
/// different kind of thing from an active state, and saying so instantly
/// is worth the exception (see the theme preamble).
in property <bool> warn: false;
/// Lit, for supporting text the pointer is currently over. Mirrors
/// `Label.emphasised` from one step further down, so the two roles brighten
/// to the same ink and a hover reads identically wherever it lands.
in property <bool> emphasised: false;
color: root.warn ? Theme.warn-ink
: (root.emphasised ? Theme.ink : Theme.ink-faint);
font-size: Theme.text-sm;
vertical-alignment: center;
}
// A region of surface holding a **column** of controls.
//
// Two shapes, because the call sites are two shapes. An inset box on the
// launch screen is bordered and rounded — it sits on the ground with air
// around it and needs its own edge. A panel in the develop column is `flat`:
// it abuts its neighbours, so the divider between them belongs to the column
// that stacks them, and a border here would double up with it.
//
// **Not every bordered box is a Panel.** The folder picker's list is the same
// surface and rule but overlays three mutually exclusive states — loading, the
// list, "nothing here" — each filling the box. This stacks its children, so it
// would lay those three out in a row; that site draws its own Rectangle and
// says why. A component that covered both would need a bool selecting between
// a layout and an overlay, which is two components wearing one name.
//
// `@children` goes in a plain VerticalLayout for the same reason `Section`'s
// body does: Slint rejects `@children` inside anything conditional, so the
// two shapes differ only in properties, never in structure.
export component Panel inherits Rectangle {
/// Abuts its neighbours: no border, no radius. The stacking parent draws
/// the dividing rule.
in property <bool> flat: false;
in property <length> spacing: Theme.gap-sm;
/// Named `inset` rather than `padding`: a Rectangle already reserves
/// `padding` for the layout it may contain, and redeclaring it is a
/// compile error rather than an override.
in property <length> inset: Theme.gap;
background: Theme.surface;
border-radius: root.flat ? 0px : Theme.radius;
border-width: root.flat ? 0px : 1px;
border-color: Theme.rule;
VerticalLayout {
padding: root.inset;
spacing: root.spacing;
alignment: start;
@children
}
}
// A single-line text entry.
//
// **The placeholder is a sibling Text, not a property.** Slint's `TextInput`
// has none of its own, and the alternative — seeding `text` and clearing it on
// focus — loses whatever the user typed if focus arrives before a keystroke.
// A Text underneath, hidden the moment anything is entered, cannot.
//
// The focus border is `active`: focus is a live state of the control, the one
// place in a form where something is *engaged*, which is precisely what that
// token is for.
export component Field inherits Rectangle {
in-out property <string> text;
in property <string> placeholder;
/// Whether the entry currently holds focus, so a caller can enable its
/// submit button from the same fact the border is drawn from.
out property <bool> has-focus: input.has-focus;
callback accepted(string);
height: Theme.touch-target;
border-radius: Theme.radius;
border-width: 1px;
border-color: input.has-focus ? Theme.active : Theme.rule;
background: Theme.surface;
input := TextInput {
text <=> root.text;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
// Inset by hand rather than by a layout: a TextInput inside a
// HorizontalLayout is sized by the layout and stops scrolling its own
// content once the text is longer than the box.
x: Theme.gap;
width: parent.width - 2 * Theme.gap;
height: 100%;
single-line: true;
accepted => { root.accepted(self.text); }
}
Text {
text: root.placeholder;
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
x: Theme.gap;
height: 100%;
visible: input.text == "";
}
}
// What a view says when it has nothing to show.
//
// Not in the S3 brief, but `app.slint` and `library.slint` had the same two
// centred lines — a `text-lg` headline over a `text-sm` explanation — and the
// distinction they draw is the load-bearing one: "still working" and "finished
// and found nothing" are different answers, and a view that conflates them
// makes a working scan look broken. One component, so neither view can drift
// into answering only half of it.
//
// The headline is the only place `text-lg` appears outside a masthead, which
// is why it is here rather than as a `Label` variant: it is a size this file
// otherwise does not hand out.
export component EmptyState inherits VerticalLayout {
in property <string> headline;
in property <string> detail;
alignment: center;
spacing: Theme.gap;
Text {
text: root.headline;
color: Theme.ink-dim;
font-size: Theme.text-lg;
horizontal-alignment: center;
}
Caption {
text: root.detail;
horizontal-alignment: center;
wrap: word-wrap;
}
}