Files
DarkRoom/ui/dr-ui/ui/collections.slint
T
dtourolle 8ad5c86ff9 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
2026-08-09 21:11:38 +02:00

567 lines
23 KiB
Plaintext

// 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;
}
}