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);
}
}
}
}
}
+650 -74
View File
@@ -1,6 +1,11 @@
import { Theme } from "theme.slint";
import { AdjustPanel, ParamRow } from "adjust.slint";
import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
import { CollectionsPanel, CollectionRow } from "collections.slint";
export { LibraryCell, TimelineBar, CollectionRow }
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
@@ -13,8 +18,15 @@ component StatusBar inherits Rectangle {
in property <int> fps;
in property <string> filename;
in property <string> position;
/// Only offered where there is a library to go back to — with files named
/// on the command line there is no grid behind this view.
in property <bool> can-return-to-library: false;
height: 28px;
callback back-to-library();
// Tall enough for a Button to sit in without the strip growing: the
// control height and this strip are both 28px by design.
height: Theme.control-height;
background: Theme.surface;
HorizontalLayout {
@@ -23,44 +35,37 @@ component StatusBar inherits Rectangle {
spacing: Theme.gap;
alignment: start;
Text {
text: root.backend;
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
vertical-alignment: center;
// Kept instantiated with `visible` rather than wrapped in an `if`:
// the strip is inside the layout that `expanded` feeds, and adding a
// conditional child here is the shape that has already caused binding
// loops in this file (see the panel below).
Button {
text: "‹ Library";
visible: root.can-return-to-library;
clicked => { root.back-to-library(); }
}
Text {
text: root.filename;
color: Theme.ink;
font-size: Theme.text-sm;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
// The backend is an identifying label, not a state — it says which
// path is in use, and it says the same thing whether that path is
// fast or slow. The accent it used to carry made every frame look
// like an alert; `fps` below is the thing here that can go wrong.
Label { text: root.backend; }
Text {
text: root.position;
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
Value { text: root.filename; compact: true; overflow: elide; }
Caption { text: root.position; }
Rectangle { horizontal-stretch: 1; }
Text {
text: root.layout-class;
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
Caption { text: root.layout-class; }
Text {
// Degraded performance, so this is a caution rather than an active
// state: the frame rate has fallen below what the compositing path is
// supposed to sustain, which is exactly the assumption A1 exists to
// test. `warn` is the one hue left in the palette and this earns it.
Caption {
text: root.fps + " fps";
color: root.fps >= 55 ? Theme.ink-dim : Theme.accent;
font-size: Theme.text-sm;
vertical-alignment: center;
warn: root.fps < 55;
}
}
@@ -78,40 +83,26 @@ component InfoPanel inherits Rectangle {
in property <string> exposure;
in property <string> dimensions;
background: Theme.surface;
height: self.preferred-height;
background: transparent;
height: panel.preferred-height;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
panel := Panel {
// Flat: this abuts the adjust panel below it and the rule between
// them is drawn by the column that stacks the two.
flat: true;
width: 100%;
Text {
text: "IMAGE";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
PanelHeading { text: "IMAGE"; }
Text {
Value {
text: root.camera == "" ? "—" : root.camera;
color: Theme.ink;
font-size: Theme.text;
placeholder: root.camera == "";
wrap: word-wrap;
}
Text {
text: root.exposure;
color: Theme.ink-dim;
font-size: Theme.text-sm;
}
Label { text: root.exposure; }
Text {
text: root.dimensions;
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
Caption { text: root.dimensions; }
}
}
@@ -138,6 +129,34 @@ export component AppWindow inherits Window {
in property <int> total: 0;
in property <string> load-error: "";
// --- zoom, pan and crop (FR-DEV-4) ---
//
// Zoom is a *viewing* state, not an edit: it changes the resolution the
// pipeline renders at, never what the file becomes. Rust owns the actual
// rect; these carry only what the interface has to draw.
in property <float> zoom: 1.0;
in property <bool> zoomed: false;
/// Whether the crop overlay is active. While it is, the canvas shows the
/// *whole* frame — otherwise the area being cropped away would not be on
/// screen to drag across — and the surround is greyed.
in-out property <bool> crop-mode: false;
/// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds.
in-out property <float> crop-x: 0.0;
in-out property <float> crop-y: 0.0;
in-out property <float> crop-w: 1.0;
in-out property <float> crop-h: 1.0;
callback crop-mode-toggled(bool);
/// A dragged crop rect, in fractions of the frame.
callback crop-changed(float, float, float, float);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
callback zoom-reset();
// --- launch screen (FR-NC-1, FR-NC-4) ---
//
// The app opens here when no library is configured, and returns here to
@@ -172,6 +191,145 @@ export component AppWindow inherits Window {
callback launch-browse-confirm();
callback launch-browse-cancel();
// --- library grid (FR-CAT-4) ---
//
// Shown after a library is opened, before an image is chosen. Cells are a
// window over the catalog, not the whole of it.
in property <bool> show-library: false;
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
in property <bool> library-scanning: false;
in property <string> library-status: "";
in property <string> library-error: "";
in property <int> library-thumbs-done: 0;
in property <int> library-thumbs-total: 0;
in property <string> library-root-label: "";
in-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
in property <string> library-window-label: "";
in property <int> library-offset: 0;
in property <int> library-sweep-done: 0;
in property <int> library-sweep-total: 0;
in property <int> library-current-bucket: 0;
in property <int> library-current-index: -1;
in property <bool> library-timeline-anchored: false;
callback library-scrub(int);
callback library-timeline-pan(int);
callback library-timeline-zoom(int);
in-out property <int> library-columns: 1;
in property <bool> library-syncing: false;
in property <int> library-scroll-to: 0;
in property <int> library-scroll-token: 0;
callback library-sync-now();
callback library-columns-changed();
callback library-scrolled(int);
callback library-capacity(int);
callback library-cell-clicked(int);
callback library-rescan();
/// Grid → launch screen, to pick a different folder or account.
callback library-change();
/// Develop → grid.
callback back-to-library();
// --- collections (FR-CAT-7) ---
//
// The sidebar's tree, flattened in Rust: Slint cannot instantiate a
// component recursively, so depth arrives as an integer per row.
in-out property <[CollectionRow]> collection-rows;
/// Which collection scopes the grid. 0 is the whole library.
in property <int> collection-selected: 0;
in property <string> collection-scope-label: "";
in property <string> collection-error: "";
/// Whether the sidebar is shown at all. Collapsed in the compact layout,
/// where 232px of the window is most of the photograph (FR-UI-1).
in property <bool> collections-visible: true;
/// Which collection is being renamed in the sidebar, by id. 0 is none.
in property <int> collection-renaming: 0;
callback collection-select(int);
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
/// Renaming started on a row, by id — a double-click, or `F2`.
callback collection-rename-start(int);
/// A rename was committed: the collection's id and the new name.
callback collection-rename-commit(int, string);
/// Renaming was abandoned with Escape.
callback collection-rename-cancel();
/// Row index and collection id under the pointer. The id is what the
/// drop highlight follows: a spring expansion rebuilds the row model
/// mid-drag, and an index would then point at a different collection.
/// A drag is dwelling over a collection, or has left it: id, and whether
/// it is over. Drives the spring-loaded expansion, timed in Rust.
callback collection-drag-over(int, bool);
/// Images were dropped on a collection, by id. Slint hit-tests the release,
/// so this is the collection actually under the pointer.
callback collection-dropped(int);
// --- trash (FR-CAT-15) ---
//
// Not a collection: a drop here moves the file into a trash folder on the
// server, where a collection drop only adds a reference.
in property <int> trash-count: 0;
in property <string> trash-label: "";
/// A soft delete: move the dragged images to the trash folder.
callback trash-dropped();
/// A hard delete: permanently remove everything in the trash.
callback trash-empty();
/// Put the selected trashed images back where they came from.
callback trash-restore();
// --- drag and drop ---
//
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
// click-versus-drag threshold, arbitration against the grid's Flickable,
// and the image drawn under the cursor. So there is no badge to position
// and no hover state to mirror here — only the payload, which Rust builds
// from the selection, and the count the header shows.
/// The drag payload, built by Rust from the current selection.
pure callback library-drag-payload() -> data-transfer;
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
/// stack where several images are being dragged. Composited in Rust.
in property <image> library-drag-image;
/// A drag began on a cell, so an unselected one can be promoted before the
/// payload is read.
callback library-drag-started(int);
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
//
// Stars and pick/reject, set from the grid and persisted to the catalog
// and the sidecar. Every image enters unrated, which is a state of its
// own rather than a zero score.
/// A star was clicked on a cell: row, then the rating 0..5.
callback library-cell-rated(int, int);
/// The trash target was clicked on one cell, by row.
callback library-cell-trashed(int);
/// Move the grid selection to the trash — the `Delete` key.
callback library-trash-selection();
/// A judgement key was pressed, applying to the whole selection. One of
/// the two arguments is -1, saying which axis was *not* meant.
callback library-judged(int, int);
in property <int> library-filter-min-rating: 0;
in property <bool> library-filter-unjudged: false;
in property <int> library-filter-flag: 0;
/// Images per star count, index 0 unrated, for the filter chips.
in-out property <[int]> library-rating-counts;
callback library-filter-min-rating-changed(int);
callback library-filter-unjudged-changed(bool);
callback library-filter-flag-changed(int);
callback next-image();
callback prev-image();
@@ -238,7 +396,143 @@ export component AppWindow inherits Window {
browse-cancel() => { root.launch-browse-cancel(); }
}
if !root.show-launch: VerticalLayout {
// The library view sits between the launch screen and develop: a library
// has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both.
if !root.show-launch && root.show-library: Rectangle {
background: Theme.ground;
HorizontalLayout {
// Always instantiated, width collapsed to zero when hidden — the
// same reasoning as the adjust panel below: an `if` here depends on
// the layout class, which derives from the window width, which the
// layout then influences. Slint flags that loop and it can panic.
Rectangle {
width: root.collections-visible ? 232px : 0px;
visible: root.collections-visible;
horizontal-stretch: 0;
CollectionsPanel {
width: 100%;
height: 100%;
rows: root.collection-rows;
selected-id: root.collection-selected;
total-images: root.library-total;
error: root.collection-error;
select(id) => { root.collection-select(id); }
toggle(id) => { root.collection-toggle(id); }
new-collection() => { root.collection-new(); }
row-menu(id) => { root.collection-menu(id); }
renaming-id: root.collection-renaming;
rename-start(id) => { root.collection-rename-start(id); }
rename-commit(id, name) => {
root.collection-rename-commit(id, name);
}
rename-cancel() => { root.collection-rename-cancel(); }
trash-count: root.trash-count;
trash-label: root.trash-label;
selected-count: root.library-selected-count;
dropped-on(id) => { root.collection-dropped(id); }
dropped-on-trash() => { root.trash-dropped(); }
empty-trash() => { root.trash-empty(); }
restore-selected() => { root.trash-restore(); }
drag-over(id, over) => { root.collection-drag-over(id, over); }
}
}
LibraryGrid {
horizontal-stretch: 1;
cells: root.library-cells;
total: root.library-total;
scanning: root.library-scanning;
scan-status: root.library-status;
scan-error: root.library-error;
thumbs-done: root.library-thumbs-done;
thumbs-total: root.library-thumbs-total;
root-label: root.library-root-label;
timeline: root.library-timeline;
timeline-label: root.library-timeline-label;
window-label: root.library-window-label;
offset: root.library-offset;
selected-count: root.library-selected-count;
scope-label: root.collection-scope-label;
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
current-bucket: root.library-current-bucket;
current-bucket-index: root.library-current-index;
timeline-anchored: root.library-timeline-anchored;
scrub(t) => { root.library-scrub(t); }
timeline-pan(d) => { root.library-timeline-pan(d); }
timeline-zoom(d) => { root.library-timeline-zoom(d); }
syncing: root.library-syncing;
scroll-to: root.library-scroll-to;
scroll-token: root.library-scroll-token;
sync-now() => { root.library-sync-now(); }
columns-changed(n) => {
root.library-columns = n;
// Which cell begins a row just changed, and month headings sit on
// row-leading cells — so they must be recomputed, not just moved.
root.library-columns-changed();
}
scrolled(i) => { root.library-scrolled(i); }
capacity-changed(n) => { root.library-capacity(n); }
cell-clicked(i) => { root.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
change-library() => { root.library-change(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
}
drag-image: root.library-drag-image;
drag-payload() => { return root.library-drag-payload(); }
drag-started(i) => { root.library-drag-started(i); }
drag-finished() => { root.library-drag-finished(); }
remove-from-collection() => {
root.library-remove-from-collection();
}
filter-min-rating: root.library-filter-min-rating;
filter-unjudged: root.library-filter-unjudged;
filter-flag: root.library-filter-flag;
rating-counts: root.library-rating-counts;
cell-rated(i, n) => { root.library-cell-rated(i, n); }
cell-trashed(i) => { root.library-cell-trashed(i); }
trash-selection() => { root.library-trash-selection(); }
// Derived from the sidebar's own selection rather than
// mirrored in a second property: `-1` is already the sentinel
// for the trash row (see collections.slint), and two sources
// for one fact is how they come to disagree.
viewing-trash: root.collection-selected == -1;
judged(rating, flag) => { root.library-judged(rating, flag); }
// `F2` renames whatever the grid is scoped to. Answered from
// the sidebar's selection rather than a second property, for
// the same reason `viewing-trash` above is: one fact, one
// source. Ids at or below 0 are "all photographs" and the
// trash, neither of which has a name to change — Rust drops
// those rather than the binding trying to reason about it.
rename-scope() => {
root.collection-rename-start(root.collection-selected);
}
filter-min-rating-changed(n) => {
root.library-filter-min-rating-changed(n);
}
filter-unjudged-toggled(on) => {
root.library-filter-unjudged-changed(on);
}
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
}
}
}
if !root.show-launch && !root.show-library: VerticalLayout {
StatusBar {
adapter: root.adapter;
backend: root.backend;
@@ -246,6 +540,10 @@ export component AppWindow inherits Window {
fps: root.fps;
filename: root.filename;
position: root.total > 0 ? root.index + 1 + " / " + root.total : "";
// Only where a library was opened; command-line files have no
// grid to return to.
can-return-to-library: root.library-total > 0;
back-to-library() => { root.back-to-library(); }
}
HorizontalLayout {
@@ -256,7 +554,7 @@ export component AppWindow inherits Window {
background: Theme.ground;
clip: true;
Image {
canvas-image := Image {
width: 100%;
height: 100%;
source: root.canvas;
@@ -264,24 +562,271 @@ export component AppWindow inherits Window {
visible: root.total > 0 && root.load-error == "";
}
// Where the photograph actually sits inside this box.
//
// `image-fit: contain` letterboxes, and Slint does not report
// the fitted rect, so it is derived from the source's aspect.
// The overlay is placed against *this*, not against the whole
// area — otherwise the crop rect drifts off the picture on any
// window whose shape differs from the image's.
property <float> src-w: root.canvas.width > 0 ? root.canvas.width : 1;
property <float> src-h: root.canvas.height > 0 ? root.canvas.height : 1;
property <float> box-w: self.width / 1px;
property <float> box-h: self.height / 1px;
property <float> fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h);
property <length> shown-w: self.src-w * self.fit-scale * 1px;
property <length> shown-h: self.src-h * self.fit-scale * 1px;
property <length> shown-x: (self.width - self.shown-w) / 2;
property <length> shown-y: (self.height - self.shown-h) / 2;
// Empty and error states say what happened rather than
// showing a blank canvas.
if root.total == 0 || root.load-error != "": VerticalLayout {
alignment: center;
spacing: Theme.gap;
Text {
text: root.load-error != "" ? "Could not load image" : "No images";
color: Theme.ink-dim;
font-size: Theme.text-lg;
horizontal-alignment: center;
if root.total == 0 || root.load-error != "": EmptyState {
headline: root.load-error != "" ? "Could not load image" : "No images";
detail: root.load-error != "" ? root.load-error
: "Pass a folder or file on the command line.";
}
// --- zoom and pan ------------------------------------------
//
// Below the crop overlay in z-order so that, in crop mode, the
// handles take the drag instead. Scroll still zooms either way.
if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0;
width: 100%;
height: 100%;
// A pan is only meaningful once there is something outside
// the viewport to reach.
mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default;
enabled: !root.crop-mode;
property <length> last-x;
property <length> last-y;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.last-x = self.mouse-x;
self.last-y = self.mouse-y;
}
}
Text {
text: root.load-error != "" ? root.load-error
: "Pass a folder or file on the command line.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
wrap: word-wrap;
moved => {
if (self.pressed && root.zoomed) {
// Fractions of the *visible* area, which is what
// the session's pan expects. Negated: dragging
// right moves the image right, so the window onto
// it moves left.
root.pan-by(
-(self.mouse-x - self.last-x) / max(parent.shown-w, 1px),
-(self.mouse-y - self.last-y) / max(parent.shown-h, 1px),
);
self.last-x = self.mouse-x;
self.last-y = self.mouse-y;
}
}
scroll-event(ev) => {
if (ev.delta-y == 0) {
return reject;
}
// Anchored on the pointer, in fractions of the shown
// image, so whatever is under the cursor stays there.
root.zoom-at(
ev.delta-y > 0 ? 1.15 : 1.0 / 1.15,
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
);
return accept;
}
double-clicked => { root.zoom-reset(); }
}
// --- crop overlay ------------------------------------------
//
// Four dimmed, desaturated panels around the crop, then the
// rect itself with handles. Panels rather than one shape with
// a hole: Slint has no cut-out, and four rectangles are exact.
if root.crop-mode && root.total > 0 && root.load-error == "": crop-overlay := Rectangle {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
property <length> rx: root.crop-x * self.width;
property <length> ry: root.crop-y * self.height;
property <length> rw: root.crop-w * self.width;
property <length> rh: root.crop-h * self.height;
// The surround: dimmed and drained of colour, so the crop
// reads as the photograph and everything else as context
// being discarded. Slint cannot desaturate a live image,
// so this is a heavy neutral wash over it — the dimming
// carries the separation and the neutrality kills the
// colour cue.
property <brush> veil: #20242aE0;
Rectangle {
x: 0;
y: 0;
width: parent.width;
height: crop-overlay.ry;
background: crop-overlay.veil;
}
Rectangle {
x: 0;
y: crop-overlay.ry + crop-overlay.rh;
width: parent.width;
height: parent.height - crop-overlay.ry - crop-overlay.rh;
background: crop-overlay.veil;
}
Rectangle {
x: 0;
y: crop-overlay.ry;
width: crop-overlay.rx;
height: crop-overlay.rh;
background: crop-overlay.veil;
}
Rectangle {
x: crop-overlay.rx + crop-overlay.rw;
y: crop-overlay.ry;
width: parent.width - crop-overlay.rx - crop-overlay.rw;
height: crop-overlay.rh;
background: crop-overlay.veil;
}
// The rect, its thirds, and the handles.
Rectangle {
x: parent.rx;
y: parent.ry;
width: parent.rw;
height: parent.rh;
border-width: 1px;
border-color: #ffffffCC;
// Rule of thirds, the reason a crop overlay is worth
// drawing at all rather than typing numbers.
Rectangle {
x: parent.width / 3;
y: 0; width: 1px; height: parent.height;
background: #ffffff44;
}
Rectangle {
x: parent.width * 2 / 3;
y: 0; width: 1px; height: parent.height;
background: #ffffff44;
}
Rectangle {
x: 0; y: parent.height / 3;
width: parent.width; height: 1px;
background: #ffffff44;
}
Rectangle {
x: 0; y: parent.height * 2 / 3;
width: parent.width; height: 1px;
background: #ffffff44;
}
// Drag the whole rect.
move-area := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: MouseCursor.move;
property <float> start-x;
property <float> start-y;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.start-x = root.crop-x;
self.start-y = root.crop-y;
}
}
moved => {
if (self.pressed) {
root.crop-changed(
self.start-x
+ (self.mouse-x - self.pressed-x)
/ max(crop-overlay.width, 1px),
self.start-y
+ (self.mouse-y - self.pressed-y)
/ max(crop-overlay.height, 1px),
root.crop-w,
root.crop-h,
);
}
}
}
}
// Corner handles. Each drags one corner while the opposite
// stays put, which is the only behaviour that lets a crop
// be shaped rather than merely moved.
for corner in [
{ hx: 0.0, hy: 0.0 },
{ hx: 1.0, hy: 0.0 },
{ hx: 0.0, hy: 1.0 },
{ hx: 1.0, hy: 1.0 },
]: Rectangle {
property <length> size: 18px;
x: parent.rx + corner.hx * parent.rw - self.size / 2;
y: parent.ry + corner.hy * parent.rh - self.size / 2;
width: self.size;
height: self.size;
Rectangle {
width: 12px;
height: 12px;
background: #ffffff;
border-radius: 2px;
}
TouchArea {
width: 100%;
height: 100%;
mouse-cursor: (corner.hx == corner.hy)
? MouseCursor.nwse-resize
: MouseCursor.nesw-resize;
property <float> ox;
property <float> oy;
property <float> ow;
property <float> oh;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.ox = root.crop-x;
self.oy = root.crop-y;
self.ow = root.crop-w;
self.oh = root.crop-h;
}
}
// Movement as a fraction of the frame, live while
// the handle is held.
property <float> dx:
(self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px);
property <float> dy:
(self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px);
moved => {
if (!self.pressed) {
return;
}
// Dragging a left/top handle moves the origin
// and shrinks the extent by the same amount;
// a right/bottom handle moves only the extent.
// Rust clamps the result, so an over-drag
// slides rather than inverting.
root.crop-changed(
corner.hx == 0 ? self.ox + self.dx : self.ox,
corner.hy == 0 ? self.oy + self.dy : self.oy,
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
);
}
}
}
}
@@ -300,6 +845,31 @@ export component AppWindow inherits Window {
}
}
// Crop and zoom controls, floating over the canvas so they are
// reachable whether or not the side panel is showing.
if root.total > 0 && root.load-error == "": HorizontalLayout {
x: 12px;
y: parent.height - self.preferred-height - 12px;
spacing: 6px;
Button {
text: root.crop-mode ? "Done" : "Crop";
active: root.crop-mode;
clicked => { root.crop-mode-toggled(!root.crop-mode); }
}
// Zoom is a view state, so its readout doubles as the
// control that clears it.
Button {
text: root.zoomed
? Math.round(root.zoom * 100) + "%"
: "Fit";
active: root.zoomed;
enabled: root.zoomed;
clicked => { root.zoom-reset(); }
}
}
// Report size changes so the render target can be resized to
// match. Width and height are tracked separately because
// Slint has no single "geometry changed" hook.
@@ -343,6 +913,12 @@ export component AppWindow inherits Window {
root.param-reset(op, param);
}
curve-reset(op) => { root.curve-reset(op); }
// A section's reset and a curve's reset are the same
// action — every parameter of one operation back to
// its default — so they share the one callback rather
// than duplicating a handler that would have to be
// kept in step with it.
op-reset(op) => { root.curve-reset(op); }
reset-all => { root.reset-all(); }
}
}
+566
View File
@@ -0,0 +1,566 @@
// The collections sidebar: a hierarchy of collections, and a drop target for
// images dragged out of the grid.
//
// TRACES: FR-CAT-7 | FR-UI-3 | FR-UI-5
//
// # Why the tree is flat here
//
// Slint has no recursive component instantiation, so a `for` cannot nest itself
// to arbitrary depth. The tree arrives from Rust already flattened, each row
// carrying its own `depth` — indentation is drawn from that integer. The
// ordering rules (siblings by name, children after their parent) live in
// `dr_catalog::collections::tree`, which is where the data model already is.
//
// # Drag and drop
//
// Each row is a `DropArea`, and the grid's cells are `DragArea`s. Slint owns the
// gesture: pointer capture, the threshold that separates a click from a drag,
// arbitration against the grid's `Flickable`, the image under the cursor, and
// delivery of the payload to whichever row the pointer is actually over.
//
// This replaced a hand-rolled version that tracked presses through `TouchArea`
// and is worth recording, because the failure was not obvious: an interactive
// `Flickable` claims any drag beginning inside it for scrolling and *cancels*
// the child TouchArea's press, so the gesture could never leave the grid. The
// hand-rolled version also had to decide the drop target from the last row
// hovered, since a captured pointer is invisible to everything else — which
// meant a tree rebuilt mid-drag could redirect the drop. `DropArea` hit-tests
// the release itself, so neither problem exists.
//
// `can-drop` runs while the cursor moves and decides whether this row will
// accept — a saved filter refuses there, so the cursor says no *before* the
// release rather than the drop being silently discarded after it.
import { Theme } from "theme.slint";
import { Button } from "widgets.slint";
// One row of the collection tree.
export struct CollectionRow {
id: int,
name: string,
// 0 for a top-level collection. Indentation is drawn from this.
depth: int,
// Direct members. A parent shows this and its descendants' total
// separately: an empty set of full children must not read as full.
direct-count: int,
// Distinct images including descendants'.
deep-count: int,
has-children: bool,
expanded: bool,
// A saved filter. Cannot be dropped onto — its membership is its selector.
smart: bool,
}
// A collection row: disclosure arrow, name, count, and a drop highlight.
component TreeRow inherits Rectangle {
in property <CollectionRow> entry;
in property <bool> selected;
/// Whether a drag hovering here could land. A saved filter's membership is
/// computed from its selector, so it refuses — and says so on hover.
in property <bool> drop-allowed: true;
/// Whether this row is being renamed, in which case its name is drawn as an
/// editable field rather than as text.
in property <bool> renaming: false;
callback clicked();
callback toggle();
/// The pointer is dwelling here mid-drag. What springs a collapsed parent
/// open, so a child can be reached without ending the drag.
callback drag-over(bool);
/// Images were dropped on this row.
callback dropped();
callback context-menu();
/// Start renaming this row — a double-click on the name.
callback rename-requested();
/// The new name, committed with Enter or by clicking away.
callback rename-committed(string);
/// Renaming abandoned with Escape; the old name stands.
callback rename-cancelled();
height: Theme.row-height;
// `drop.has-drag` is only true once `can-drop` has accepted, so a refused
// row never lights up as though it would take the images.
background: drop.has-drag
? Theme.selected
: (root.selected ? Theme.surface-raised
: (touch.has-hover ? Theme.hover : transparent));
border-radius: Theme.radius-sm;
// A drop target is outlined as well as filled: on a dark ground a fill
// change alone is easy to miss mid-drag, when the user is watching the
// thumbnail under the cursor rather than the row.
border-width: drop.has-drag ? 1px : 0px;
border-color: Theme.selected-ring;
// Behind the content, so the row's own TouchArea still gets ordinary
// clicks. A DropArea only takes part in a drag; it does not block presses.
drop := DropArea {
width: 100%;
height: 100%;
can-drop(ev) => {
// Refused here rather than after the release: the cursor shows
// "no" while the user can still aim somewhere else.
if (!root.drop-allowed) {
return DragAction.none;
}
return DragAction.copy;
}
dropped(ev) => {
root.dropped();
return DragAction.copy;
}
// Dwelling over a collapsed parent springs it open. Reported rather
// than acted on here, because the dwell timer and the collapse state
// live in Rust with the rest of the tree.
changed has-drag => { root.drag-over(self.has-drag); }
}
HorizontalLayout {
// Indentation from depth. The guide rail below sits in this space.
padding-left: Theme.gap-sm + root.entry.depth * Theme.indent;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
// Disclosure arrow, or an equal blank so names stay aligned between
// rows that have children and rows that do not.
Rectangle {
width: 14px;
if root.entry.has-children: Text {
text: root.entry.expanded ? "▾" : "▸";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
// Its own hit area: toggling open must not also select, or every
// expand reloads the grid.
if root.entry.has-children: TouchArea {
clicked => { root.toggle(); }
}
}
// Smart collections read differently from manual ones — the icon is
// the only cue that its contents are computed, and that dropping
// images on it will be refused.
Text {
text: root.entry.smart ? "◈" : "▤";
color: root.entry.smart ? Theme.active-dim : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
// The name, or the field that is replacing it while this row is being
// renamed. Two branches of one `if` rather than a TextInput styled to
// look like text at rest: a live TextInput would swallow the clicks
// that select the collection and the presses that begin a drag.
if !root.renaming: Text {
text: root.entry.name;
color: root.selected ? Theme.ink : Theme.ink-dim;
font-size: Theme.text;
font-weight: root.selected ? 600 : 400;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
if root.renaming: Rectangle {
horizontal-stretch: 1;
background: Theme.ground;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.active;
edit := TextInput {
// Seeded once, when the field appears. Bound two-way to the
// row's name it would rewrite the model on every keystroke,
// and an edit abandoned with Escape could not be undone.
text: root.entry.name;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
single-line: true;
// Inset by hand for the same reason `Field` does it: inside a
// layout the input stops scrolling its own content once the
// text outgrows the box.
x: Theme.gap-sm;
width: parent.width - 2 * Theme.gap-sm;
height: 100%;
// Focus and a full selection on appearance, so the placeholder
// name a new collection arrives with is replaced by typing
// rather than having to be cleared first.
init => {
self.focus();
self.select-all();
}
accepted => { root.rename-committed(self.text); }
key-pressed(event) => {
// Escape abandons. Handled here rather than on a
// surrounding FocusScope, which would never see the key —
// the input has focus and consumes it.
if (event.text == Key.Escape) {
root.rename-cancelled();
return accept;
}
return reject;
}
// Clicking away commits rather than discarding: the text is
// visible on screen and the user typed it, so throwing it out
// for want of an Enter is the surprising choice.
changed has-focus => {
if (!self.has-focus) {
root.rename-committed(self.text);
}
}
}
}
// The count. A parent shows its deep total, since its own direct
// membership is usually zero and "0" beside a full subtree reads as
// broken. The distinction is spelled out in the tooltip-less way
// available here: parentheses mean "including children".
Text {
text: root.entry.has-children && root.entry.deep-count != root.entry.direct-count
? "(" + root.entry.deep-count + ")"
: (root.entry.direct-count > 0 ? root.entry.direct-count : "")
+ "";
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
touch := TouchArea {
// Disabled while the field is up, so a click landing on the row rather
// than inside the input does not re-select the collection out from
// under the edit in progress.
enabled: !root.renaming;
clicked => { root.clicked(); }
// Double-click renames — the gesture a file manager or a Lightroom
// panel uses for the same thing, so it needs no discovering.
double-clicked => { root.rename-requested(); }
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
root.context-menu();
}
}
}
}
export component CollectionsPanel inherits Rectangle {
in property <[CollectionRow]> rows;
// Which collection scopes the grid. 0 means the whole library.
in property <int> selected-id: 0;
in property <int> total-images: 0;
in property <string> error: "";
callback select(int);
callback toggle(int);
/// Images were dropped on a collection, by its id. Slint hit-tests the
/// release itself, so this is the collection actually under the pointer —
/// not the last one hovered.
callback dropped-on(int);
/// A drag is dwelling over a collection, or has left it. Drives the
/// spring-loaded expansion, which is timed in Rust.
callback drag-over(int, bool);
callback new-collection();
// Right-click on a row: rename, delete, new child.
callback row-menu(int);
/// Which collection is being renamed, by id. 0 is none.
///
/// Driven from Rust rather than held here, because a rename that fails —
/// or one begun by *creating* a collection, which happens before this
/// panel has the new row — has to be started and ended from that side.
in property <int> renaming-id: 0;
/// Renaming began on a row: its id.
callback rename-start(int);
/// A rename was committed: the id, and the new name.
callback rename-commit(int, string);
/// Renaming was abandoned.
callback rename-cancel();
// --- trash (FR-CAT-15) ---
//
// `selected-id == -1` is the trash being viewed. A sentinel rather than a
// separate bool because the sidebar has exactly one selection, and two
// flags could disagree about what the grid is showing.
/// How many images are in the trash.
in property <int> trash-count: 0;
/// The count and the bytes it holds, already formatted — "12 · 340 MB".
/// Formatted in Rust because Slint has no byte-size formatting and the
/// arithmetic would be unreadable inline.
in property <string> trash-label: "";
/// How many grid cells are selected, so the trash view can offer to restore
/// them. Read here as well as in the library panel because restoring is a
/// selection action and the button has to say what it will act on.
in property <int> selected-count: 0;
/// Images were dropped on the trash: a soft delete.
callback dropped-on-trash();
/// Permanently delete everything in the trash.
callback empty-trash();
/// Put the selected images back where they came from.
callback restore-selected();
width: 232px;
background: Theme.surface;
VerticalLayout {
padding: Theme.gap-sm;
spacing: Theme.gap-sm;
// --- header -------------------------------------------------------
HorizontalLayout {
height: 26px;
spacing: Theme.gap-sm;
Text {
text: "COLLECTIONS";
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
vertical-alignment: center;
horizontal-stretch: 1;
}
// New collection. A glyph rather than a word: the header is 232px
// wide and the label would crowd out the title.
Rectangle {
width: 22px;
height: 22px;
y: (parent.height - self.height) / 2;
background: add-touch.pressed ? Theme.pressed
: (add-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
Text {
text: "+";
color: Theme.ink-dim;
font-size: Theme.text-lg;
horizontal-alignment: center;
vertical-alignment: center;
}
add-touch := TouchArea {
clicked => { root.new-collection(); }
}
}
}
// --- the whole library --------------------------------------------
//
// Always first and never nested: it is how the user gets back to an
// unscoped grid, and burying it inside the tree would make "show me
// everything" a thing you have to find.
Rectangle {
height: Theme.row-height;
background: root.selected-id == 0 ? Theme.surface-raised
: (all-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: "All photographs";
color: root.selected-id == 0 ? Theme.ink : Theme.ink-dim;
font-size: Theme.text;
font-weight: root.selected-id == 0 ? 600 : 400;
vertical-alignment: center;
horizontal-stretch: 1;
}
Text {
text: root.total-images > 0 ? root.total-images : "";
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
all-touch := TouchArea {
clicked => { root.select(0); }
// Dropping onto "All photographs" would mean nothing — an
// image is already in the library — so this reports no hover
// during a drag and stays inert.
}
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// --- the tree -----------------------------------------------------
Flickable {
vertical-stretch: 1;
viewport-height: root.rows.length * (Theme.row-height + 2px);
for row[i] in root.rows: TreeRow {
y: i * (Theme.row-height + 2px);
width: parent.width;
entry: row;
selected: row.id == root.selected-id;
// A saved filter's membership is computed, so a drop cannot
// land there. Refused in `can-drop`, so the cursor says no
// before the release rather than after.
drop-allowed: !row.smart;
renaming: row.id == root.renaming-id;
clicked => { root.select(row.id); }
toggle => { root.toggle(row.id); }
context-menu => { root.row-menu(row.id); }
dropped => { root.dropped-on(row.id); }
drag-over(over) => { root.drag-over(row.id, over); }
rename-requested => { root.rename-start(row.id); }
rename-committed(name) => { root.rename-commit(row.id, name); }
rename-cancelled => { root.rename-cancel(); }
}
// Empty state. A blank panel gives no hint that collections exist
// at all, let alone that images can be dragged into them.
if root.rows.length == 0: VerticalLayout {
alignment: center;
spacing: Theme.gap-sm;
padding: Theme.gap;
Text {
text: "No collections yet";
color: Theme.ink-dim;
font-size: Theme.text-sm;
horizontal-alignment: center;
}
Text {
text: "Press + to make one, then drag photographs onto it.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
wrap: word-wrap;
}
}
}
// --- trash --------------------------------------------------------
//
// TRACES: FR-CAT-15
// Below the tree and separated from it, because it is not a collection:
// dropping here *moves the file* into a trash folder on the server,
// where every collection above merely references. A destination that
// changes the library has no business sitting in the same list as ones
// that do not.
Rectangle {
height: 1px;
background: Theme.rule;
}
Rectangle {
height: Theme.row-height;
background: trash-drop.has-drag
? Theme.selected
: (root.selected-id == -1 ? Theme.surface-raised
: (trash-touch.has-hover ? Theme.hover : transparent));
border-radius: Theme.radius-sm;
border-width: trash-drop.has-drag ? 1px : 0px;
border-color: Theme.warn-ink;
trash-drop := DropArea {
width: 100%;
height: 100%;
// Deliberately `move`, where a collection drop is `copy`: this
// one really does take the photograph out of the library, and
// the cursor should say so.
can-drop(ev) => { return DragAction.move; }
dropped(ev) => {
root.dropped-on-trash();
return DragAction.move;
}
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: "🗑";
color: root.trash-count > 0 ? Theme.warn-ink : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
Text {
text: "Trash";
color: root.selected-id == -1 ? Theme.ink : Theme.ink-dim;
font-size: Theme.text;
font-weight: root.selected-id == -1 ? 600 : 400;
vertical-alignment: center;
horizontal-stretch: 1;
}
// The size, not just the count: "empty trash" is destructive and
// what it frees is what tells the user whether they meant it.
Text {
text: root.trash-count > 0 ? root.trash-label : "";
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
trash-touch := TouchArea {
clicked => { root.select(-1); }
}
}
// Restore sits *above* Empty, and is the only one of the two that names
// a number. The recoverable action should be the one the hand reaches
// first, and the destructive one should not be what a user finds when
// they open the trash looking for a way back.
if root.selected-id == -1 && root.selected-count > 0: Button {
text: root.selected-count == 1
? "Restore 1 image"
: "Restore " + root.selected-count + " images";
clicked => { root.restore-selected(); }
}
// Emptying is offered only while the trash is being *looked at*, so it
// cannot be hit in passing. It is the one irreversible action in this
// panel and it should take a deliberate visit to reach.
if root.selected-id == -1 && root.trash-count > 0: Button {
text: "Empty trash";
clicked => { root.empty-trash(); }
}
// --- error --------------------------------------------------------
//
// A refused drop or a failed rename says so here rather than only in
// the log: the gesture succeeded from the user's point of view, so
// silence would read as data loss.
if root.error != "": Text {
text: root.error;
color: Theme.warn-ink;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
// Right edge, separating the panel from the grid.
Rectangle {
x: parent.width - 1px;
width: 1px;
background: Theme.rule;
}
}
+77 -191
View File
@@ -1,4 +1,5 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint";
// Launch screen: connect an account, or resume a saved one.
//
@@ -35,12 +36,17 @@ component FormatCheck inherits Rectangle {
y: (parent.height - self.height) / 2;
border-radius: 3px;
border-width: 1px;
border-color: root.checked ? Theme.accent : Theme.rule;
background: root.checked ? Theme.accent : transparent;
// A ticked box is an engaged control, so it fills with `active`
// — the same token the slider fill and the focus border take.
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Text {
text: "✓";
color: #fff;
// Dark on the fill: `active` is near-white, and the white
// tick this carried against a saturated accent is invisible
// against it.
color: Theme.ground;
font-size: 12px;
visible: root.checked;
horizontal-alignment: center;
@@ -50,45 +56,20 @@ component FormatCheck inherits Rectangle {
}
}
Text {
Label {
text: root.label;
color: touch.has-hover ? Theme.ink : Theme.ink-dim;
font-size: Theme.text;
vertical-alignment: center;
emphasised: touch.has-hover;
body: true;
}
}
}
component Button inherits Rectangle {
in property <string> label;
in property <bool> primary: false;
in property <bool> enabled: true;
callback clicked();
// This screen's buttons are form actions in a single stacked column, not
// chrome beside a photograph — they are given the full `touch-target` height
// rather than the denser `control-height` the toolbars use. `Button` draws
// its own hit area at least this tall either way; here the ink matches it.
component FormButton inherits Button {
height: Theme.touch-target;
border-radius: 4px;
background: root.primary
? (touch.has-hover && root.enabled ? Theme.accent-dim : Theme.accent)
: (touch.has-hover && root.enabled ? Theme.surface-raised : transparent);
border-width: root.primary ? 0px : 1px;
border-color: Theme.rule;
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
clicked => { root.clicked(); }
}
Text {
text: root.label;
color: root.primary ? #fff : Theme.ink;
font-size: Theme.text;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// One folder in the picker. The whole row is the target, not just the text.
@@ -108,20 +89,8 @@ component FolderRow inherits Rectangle {
padding-right: Theme.gap;
spacing: Theme.gap;
Text {
text: root.is-parent ? "↑" : "▸";
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
width: 14px;
}
Text {
text: root.label;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
overflow: elide;
}
Disclosure { text: root.is-parent ? "↑" : "▸"; }
Value { text: root.label; overflow: elide; }
}
}
@@ -178,6 +147,9 @@ export component LaunchScreen inherits Rectangle {
// --- masthead ---
VerticalLayout {
spacing: Theme.gap-sm;
// The one piece of type in the app that is a title rather
// than a label, so it is written here rather than given a
// component with a single call site.
Text {
text: "DarkRoom";
color: Theme.ink;
@@ -185,12 +157,11 @@ export component LaunchScreen inherits Rectangle {
font-weight: 800;
letter-spacing: -0.5px;
}
Text {
Label {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
color: Theme.ink-dim;
font-size: Theme.text;
body: true;
}
}
@@ -200,63 +171,29 @@ export component LaunchScreen inherits Rectangle {
if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap;
Text {
text: "SERVER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
PanelHeading { text: "SERVER"; }
server-input := Field {
text: root.server-url;
placeholder: "https://cloud.example.com";
accepted(url) => { root.sign-in(url); }
}
Rectangle {
height: Theme.touch-target;
border-radius: 4px;
border-width: 1px;
border-color: server-input.has-focus ? Theme.accent : Theme.rule;
background: Theme.surface;
server-input := TextInput {
text: root.server-url;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
width: parent.width - 2 * Theme.gap;
x: Theme.gap;
height: 100%;
single-line: true;
accepted => { root.sign-in(self.text); }
}
// Placeholder, since TextInput has none of its own.
Text {
text: "https://cloud.example.com";
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
x: Theme.gap;
height: 100%;
visible: server-input.text == "";
}
}
if !root.can-remember: Text {
if !root.can-remember: Caption {
text: "No system keyring found — you will need to sign in each time.";
color: Theme.warn-ink;
font-size: Theme.text-sm;
warn: true;
wrap: word-wrap;
}
Button {
label: root.busy ? "Connecting…" : "Sign in";
FormButton {
text: root.busy ? "Connecting…" : "Sign in";
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Text {
Caption {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
@@ -265,77 +202,52 @@ export component LaunchScreen inherits Rectangle {
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
Text {
text: "APPROVE IN YOUR BROWSER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
Rectangle {
background: Theme.surface;
border-radius: 4px;
border-width: 1px;
border-color: Theme.rule;
VerticalLayout {
padding: Theme.gap;
Text {
text: root.login-url;
color: Theme.ink-dim;
font-size: Theme.text-sm;
wrap: char-wrap;
}
Panel {
Label {
text: root.login-url;
// char-wrap, not word-wrap: a URL has no spaces to
// break at, and word-wrap would run it off the box.
wrap: char-wrap;
}
}
Button {
label: "Copy link";
FormButton {
text: "Copy link";
clicked => { root.copy-login-url(); }
}
Text {
text: "Waiting for approval…";
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
Caption { text: "Waiting for approval…"; }
}
// --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
Text {
text: "CHOOSE LIBRARY FOLDER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
// Current location, so it is always clear what
// "Use this folder" would select.
Text {
Value {
text: root.browse-path == "" ? "/" : "/" + root.browse-path;
color: Theme.ink;
font-size: Theme.text;
overflow: elide;
}
// Not a `Panel`: this box overlays three mutually
// exclusive states — loading, the list, "nothing here" —
// each filling it, and Panel stacks its children in a
// column. Same surface and rule, drawn directly.
Rectangle {
height: 220px;
background: Theme.surface;
border-radius: 4px;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
if root.browse-loading: Text {
if root.browse-loading: Caption {
text: "Loading…";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
@@ -364,12 +276,9 @@ export component LaunchScreen inherits Rectangle {
}
if !root.browse-loading && root.browse-entries.length == 0
&& root.browse-path != "": Text {
&& root.browse-path != "": Caption {
text: "No subfolders here";
color: Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
@@ -377,13 +286,13 @@ export component LaunchScreen inherits Rectangle {
HorizontalLayout {
spacing: Theme.gap;
Button {
label: "Cancel";
FormButton {
text: "Cancel";
horizontal-stretch: 1;
clicked => { root.browse-cancel(); }
}
Button {
label: "Use this folder";
FormButton {
text: "Use this folder";
primary: true;
horizontal-stretch: 1;
clicked => { root.browse-confirm(); }
@@ -395,40 +304,22 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
Text {
text: "ACCOUNT";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.account;
color: Theme.ink;
font-size: Theme.text;
}
PanelHeading { text: "ACCOUNT"; }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
Text {
text: "LIBRARY FOLDER";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
PanelHeading { text: "LIBRARY FOLDER"; }
HorizontalLayout {
spacing: Theme.gap;
Text {
Value {
text: root.library-root == "" ? "(not chosen)" : root.library-root;
color: root.library-root == "" ? Theme.ink-faint : Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
Button {
label: "Choose…";
FormButton {
text: "Choose…";
width: 110px;
clicked => { root.choose-folder(); }
}
@@ -436,13 +327,7 @@ export component LaunchScreen inherits Rectangle {
Rectangle { height: Theme.gap-sm; }
Text {
text: "SCAN FOR";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
PanelHeading { text: "SCAN FOR"; }
for label[i] in root.format-labels: FormatCheck {
label: label;
@@ -452,29 +337,30 @@ export component LaunchScreen inherits Rectangle {
Rectangle { height: Theme.gap; }
Button {
label: root.busy ? "Scanning…" : "Open library";
FormButton {
text: root.busy ? "Scanning…" : "Open library";
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
Button {
label: "Sign out";
FormButton {
text: "Sign out";
clicked => { root.sign-out(); }
}
}
// --- feedback ---
if root.status != "": Text {
if root.status != "": Label {
text: root.status;
color: Theme.ink-dim;
font-size: Theme.text-sm;
wrap: word-wrap;
}
if root.error != "": Text {
// A failed sign-in or scan is a caution, not an active state:
// something the user has to act on rather than something the
// app is doing. It was the same accent as the headings above
// it, which said nothing had gone wrong.
if root.error != "": Caption {
text: root.error;
color: Theme.accent;
font-size: Theme.text-sm;
warn: true;
wrap: word-wrap;
}
}
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
// Darkroom safelight palette. Warm neutrals, single red accent.
// Committed to a dark ground — this is a photo editor, and a light UI
// surrounding an image biases how that image is judged.
export global Theme {
out property <color> ground: #14120F;
out property <color> surface: #1D1A16;
out property <color> surface-raised: #262119;
out property <color> rule: #332C24;
out property <color> ink: #F0EAE0;
out property <color> ink-dim: #A79E91;
out property <color> ink-faint: #7C7367;
out property <color> accent: #D9543C;
out property <color> accent-dim: #8F2E1E;
// Semantic, distinct from the accent: a caution is not an action.
out property <color> warn-ink: #C99A4A;
out property <length> gap-sm: 6px;
out property <length> gap: 12px;
out property <length> gap-lg: 20px;
out property <length> text-sm: 11px;
out property <length> text: 13px;
out property <length> text-lg: 17px;
out property <length> text-xl: 24px;
// FR-UI-3: minimum 44pt hit target under touch.
out property <length> touch-target: 44px;
}
+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;
}
}