Files
DarkRoom/ui/dr-ui/ui/collections.slint
T
dtourolle 005dc3a835 Put a scrollbar on the collections sidebar
A tree longer than the panel looked, to a mouse, like the whole tree.
Same shape as the develop column: the Flickable fills a Rectangle that
takes its stretch, and a ScrollBar is drawn over its right edge. It is
drawn only when the tree overflows.
2026-09-26 07:25:07 -04:00

1516 lines
66 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 { Duplicates } from "duplicates.slint";
import { Keys } from "keys.slint";
import { Button, Icon, ScrollBar } 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,
// TRACES: FR-NC-6a
// Whether every photograph in here — and in its children — is pinned for
// offline use. A collection holding nothing is never `pinned`: "all zero of
// them are kept" is true and useless, and it would draw a full tray beside
// an empty collection.
pinned: bool,
// Pinned in part: some of what is in here is kept, some is not. Distinct
// from `pinned` because the two say different things to a user about to
// board an aeroplane, and one bool could only say the more optimistic of
// them.
partly-pinned: 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;
/// TRACES: FR-UI-3 | FR-UI-4
/// Picked up by a hold, and now the thing a movement would carry.
///
/// Drawn before any movement happens, which is the whole point: the hold
/// has committed the next drag to *rearranging the tree* rather than
/// scrolling it, and nothing else on screen would say so.
in property <bool> lifted: 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();
/// TRACES: FR-NC-6a
/// Ask what should happen to this collection's local copies — keep them, or
/// give the disk back. The question is asked rather than a toggle silently
/// flipped, because both answers are expensive: one starts a download of
/// gigabytes, the other deletes them.
callback offline-menu();
/// The row is being held, or has been let go. A long press is the touch
/// route to [`offline-menu`], and the timer that decides how long is long
/// lives in Rust — Slint has no long-press gesture, and a hand-rolled one
/// here would need a `Timer` per row in the tree.
callback press-changed(bool);
/// 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();
/// TRACES: FR-UI-5
/// Enter or Escape finished the name, and the keyboard should go back to
/// whatever had it. Not raised when the name is committed by clicking
/// away: the click has already put focus where the user wanted it.
callback keys-released();
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.lifted || 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.
//
// A *lifted* row is outlined in the active colour instead, and the
// distinction matters: one ring says "let go here", the other says "this
// is what you are holding". Drawn the moment the hold fires, so the user
// knows the drag is armed before they move rather than after.
border-width: (drop.has-drag || root.lifted) ? 1px : 0px;
border-color: root.lifted ? Theme.active : 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;
}
// The action the *source* offered, echoed back, rather than a
// constant. Two different things are dropped on this row — a
// selection of photographs, whose DragArea allows only `copy`, and
// a collection being nested, whose DragArea allows only `move` —
// and the runtime clamps whatever is returned here against the
// source's allowed set, treating anything outside it as `none`.
//
// So a hard-coded `copy` accepted images and *silently refused
// every collection*: nesting by drag could not work, and it failed
// with no error anywhere, because a clamped-to-none action is
// indistinguishable from a target that simply said no.
return ev.proposed-action;
}
dropped(ev) => {
root.dropped();
return ev.proposed-action;
}
// 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: Icon {
name: root.entry.expanded ? "chevron-down" : "chevron-right";
ink: Theme.ink-faint;
size: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
// 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.
Icon {
name: root.entry.smart ? "collection-smart" : "collection";
ink: root.entry.smart ? Theme.active-dim : Theme.ink-faint;
size: 12px;
y: (parent.height - self.height) / 2;
}
// 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); }
// KEYMAP: Collections sidebar
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 (Keys.chord(event) == "Escape") {
root.rename-cancelled();
root.keys-released();
return accept;
}
// Enter keeps the name. The input's own `accepted`
// would do the same; answering it here is what puts the
// key where the checker can see it bound.
if (Keys.chord(event) == "Enter") {
root.rename-committed(self.text);
root.keys-released();
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);
}
}
}
}
// TRACES: FR-NC-6a | FR-NC-6c
// Keep this collection on the device. Here rather than only in the
// grid header because the header's version acts on whatever the grid
// is *scoped* to: reaching it means selecting the collection, closing
// the sidebar, opening the action row and finding the button — four
// steps on a tablet to say "take this trip with me". This is one.
//
// Drawn only where there is something to keep, and on a row that is
// already keeping something: an outline tray beside every collection
// in the tree is chrome the eye has to filter past to read the names,
// which is what the sidebar is actually for. A smart collection is
// excluded for the same reason it refuses a drop — its membership is a
// rule that has not been evaluated here.
if !root.entry.smart
&& (root.entry.deep-count > 0 || root.entry.direct-count > 0)
&& (root.selected || root.entry.pinned || root.entry.partly-pinned
|| touch.has-hover): Rectangle {
width: 18px;
y: (parent.height - self.height) / 2;
height: 18px;
Icon {
name: root.entry.pinned ? "offline-held" : "offline";
// Held is the state worth colouring: it is a promise the app
// has made about a gigabyte of transfer, and it should be
// legible from across the room. A partial hold is dimmer than
// a full one and brighter than an offer, which is the order
// the three actually stand in.
ink: root.entry.pinned ? Theme.active
: (root.entry.partly-pinned ? Theme.active-dim : Theme.ink-faint);
size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
// Wider than it is drawn, and no taller. A 44px square here — the
// touch minimum — would be 18px taller than the row it sits in and
// would take presses aimed at the collections above and below it,
// which is a worse failure than a small target: the user hits a
// row and a different collection's download starts.
TouchArea {
width: 30px;
height: parent.height;
x: (parent.width - self.width) / 2;
clicked => { root.offline-menu(); }
}
}
// 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;
}
}
// GESTURE: Pick a collection up to rearrange the tree
// where: Collections sidebar
// touch: Press and hold it until it lifts, then drag it
// pointer: Drag it, or hold it until it lifts and then drag
// why: The tree is inside a Flickable, which claims any drag
// beginning inside it — so with a finger a drag on a row is a
// scroll until something says otherwise. The hold is that
// something, and it is what every mobile list already uses to
// pick a row up. The row lifts the moment it fires, so the
// gesture says it has been understood before anything moves.
// manual: collections
//
// GESTURE: Act on a collection — rename, nest, un-nest, delete
// where: Collections sidebar
// touch: Press and hold the collection, then let go without moving
// pointer: Right-click it
// why: The hold arms a drag and opens this menu, and which one you
// get is decided by whether you moved — the same fork the grid
// uses. One menu for everything done to a row, because there is
// one hold per row: while the hold opened the offline question
// by itself, nothing else the tree can do had a touch route.
// manual: collections
//
// GESTURE: Take a collection back out of the one it is nested in
// where: Collections sidebar
// touch: Hold it, then drag it onto "All photographs" — or let go and
// choose "Move to top level"
// pointer: Drag it onto "All photographs", or right-click it and choose
// "Move to top level"
// why: Nesting is a drag of one row onto another, and its inverse
// had no gesture at all: "All photographs" refused every drop,
// which is right for a photograph — it is already in the
// library — and wrong for a collection, which has a top level
// to be returned to. Without it a collection dragged into
// another was in there permanently.
// manual: collections
//
// GESTURE: Rename a collection
// where: Collections sidebar
// touch: Hold the collection, then "Rename"
// pointer: Double-click its name, or right-click it and choose "Rename"
// keys: Type the name and press `Enter`; `Escape` abandons it
// why: Double-click is what a file manager and a Lightroom panel use
// for the same thing, so it needs no discovering — but nothing
// on screen says so, which is what the menu item is for.
// manual: collections
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();
return;
}
// The hold that opens the offline question. Reported both ways:
// Rust starts a timer on the press and cancels it on the release,
// so a tap that happens to be slow is still a tap.
//
// `cancel` matters as much as `up` here — the sidebar's Flickable
// takes the pointer the moment the finger travels, and a press
// that became a scroll must not fire a dialogue over the list the
// user is scrolling.
if (ev.kind == PointerEventKind.down) {
root.press-changed(true);
}
if (ev.kind == PointerEventKind.up || ev.kind == PointerEventKind.cancel) {
root.press-changed(false);
}
}
}
}
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: "";
/// Whether a scan is running, which is when neither of the two actions at
/// the foot of this panel can be honoured: pinning walks a collection the
/// scan is still writing, and changing library pulls the catalog out from
/// under it.
in property <bool> scanning: false;
// --- what to do with the collection this panel has scoped --------------
//
// TRACES: FR-NC-6a
// These two used to be buttons in the library header, where they were the
// only things in a row of library-wide actions that referred to *one
// collection* — and where "Keep offline" could only ever mean the scoped
// one while sitting nowhere near the tree that says which that is.
//
// Here they are under the tree, in the panel whose selection decides what
// they act on, and in the same place and shape the trash already puts its
// own two. The header keeps only what belongs to the whole library.
/// TRACES: FR-NC-6a
/// Whether the scoped collection is being kept on this device.
in property <bool> scope-pinned: false;
/// TRACES: FR-NC-6a
/// Ask about the scoped collection's local copies — the same question the
/// tray on a row and a held row open.
callback toggle-pin-scope();
/// Open a different library altogether.
callback change-library();
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);
/// The payload for dragging a *collection*, so the tree can be rearranged.
/// Built when the drag starts, and it is what tells `dropped-on` it is
/// being handed a collection to reparent rather than photographs to file.
pure callback collection-drag-payload(int) -> data-transfer;
/// 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);
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
//
// A press and hold arms the drag and lifts the row; moving then rearranges
// the tree, and letting go without moving opens the row menu instead.
//
// The hold is what makes this reliable with a finger. This tree is inside
// a Flickable, and a Flickable claims any drag beginning inside it — so a
// touch drag on a row is a scroll until something says otherwise. The hold
// is that something, and it is the gesture every mobile list already uses
// to pick a row up, so it needs no discovering.
/// Which collection is picked up, by id. 0 is none.
in property <int> lifted-id: 0;
/// A row's drag crossed the threshold, or ended. What tells Rust the hold
/// became a rearrangement rather than a menu.
callback drag-active(int, bool);
/// Whether a drop on "All photographs" would do anything — true only while
/// a *nested* collection is in flight. Rust decides, because only it knows
/// what the drag is carrying and whether that thing has a parent.
in property <bool> root-drop-allowed: false;
/// A collection was dropped on "All photographs": take it out to the top
/// level. The inverse of dropping one row onto another.
callback dropped-on-root();
callback new-collection();
// Right-click on a row: rename, delete, new child.
callback row-menu(int);
/// TRACES: FR-NC-6a
/// Ask about a collection's local copies, by id. Reached by tapping the
/// tray on a row, or by holding the row itself.
callback offline-menu(int);
/// A row is being held, or was let go: its id, and which. The long-press
/// timer is in Rust; see `TreeRow.press-changed`.
callback row-press(int, bool);
/// 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();
/// A key finished a rename; see `CollectionRow.keys-released`.
callback keys-released();
// --- 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 mark 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;
accessible-role: button;
accessible-label: "New collection";
accessible-action-default => { root.new-collection(); }
Icon {
name: "plus";
ink: Theme.ink-dim;
size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
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: all-drop.has-drag
? Theme.selected
: (root.selected-id == 0 ? Theme.surface-raised
: (all-touch.has-hover ? Theme.hover : transparent));
border-radius: Theme.radius-sm;
border-width: all-drop.has-drag ? 1px : 0px;
border-color: Theme.selected-ring;
// TRACES: FR-CAT-7
// Dropping a *collection* here takes it out to the top level —
// the inverse of dropping one row onto another, which nests it.
//
// Photographs still cannot be dropped here, and the refusal is the
// same one this row has always made: an image is already in the
// library, so there is nothing for the drop to do. `can-drop`
// separates the two, so the cursor says which of them this is
// before the release rather than after.
all-drop := DropArea {
width: 100%;
height: 100%;
can-drop(ev) => {
if (!root.root-drop-allowed) {
return DragAction.none;
}
// Echoed rather than named, as everywhere else here: only
// a collection can reach this branch, and its DragArea
// allows `move` — but saying so twice is how the two drift
// apart.
return ev.proposed-action;
}
dropped(ev) => {
root.dropped-on-root();
return ev.proposed-action;
}
}
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); }
}
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// --- the tree -----------------------------------------------------
Rectangle {
vertical-stretch: 1;
tree-flick := Flickable {
width: parent.width;
height: parent.height;
viewport-height: root.rows.length * (Theme.row-height + 2px);
// Wrapped rather than made draggable from the inside: the grid's
// cells take the same shape, and a `DragArea` around the whole row
// keeps the row's own TouchArea nested under it, which is what lets
// a click still select while a drag reparents.
for row[i] in root.rows: DragArea {
y: i * (Theme.row-height + 2px);
width: parent.width;
height: Theme.row-height;
// Move, not copy. A collection has one parent, so dragging it
// somewhere takes it out of where it was — unlike a photograph,
// which is filed in as many collections as you like.
allow-move: true;
data: root.collection-drag-payload(row.id);
// The hold armed this drag and lifted the row; this is where
// Rust learns the movement actually began, so the release that
// ends it opens no menu.
changed dragging => { root.drag-active(row.id, self.dragging); }
TreeRow {
width: 100%;
height: 100%;
entry: row;
selected: row.id == root.selected-id;
lifted: row.id == root.lifted-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); }
offline-menu => { root.offline-menu(row.id); }
press-changed(down) => { root.row-press(row.id, down); }
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(); }
keys-released => { root.keys-released(); }
}
}
// 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;
}
}
}
// A bar for a pointer: a tree of collections longer than the panel
// otherwise looks, to a mouse, like the whole tree.
ScrollBar {
x: parent.width - self.width;
y: 0px;
viewport-y <=> tree-flick.viewport-y;
viewport-height: tree-flick.viewport-height;
visible-height: tree-flick.height;
}
}
// TRACES: FR-NC-6a
// Keep the scoped collection on this device.
//
// Only while one is scoped: "pin the whole library" is a different and
// much more expensive request, and a button that meant either
// depending on invisible state would be a trap. Ids at or below zero
// are "All photographs" and the trash, neither of which is a
// collection to pin.
//
// No tick on the kept label: `active` already inverts the button,
// which says the same thing without a symbol inside a string.
//
// It opens the same question the tray on a row and a held row open,
// rather than pinning outright — see the note on the callback in
// `library_ui::wire`. This is the version with a word on it, for the
// collection the grid is actually showing.
if root.selected-id > 0: Button {
text: root.scope-pinned ? "Kept offline" : "Keep offline";
active: root.scope-pinned;
enabled: !root.scanning;
clicked => { root.toggle-pin-scope(); }
}
// --- 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%;
// Echoed from the source, like every other target here: the
// grid's cells allow `copy` alone, so the `move` this used to
// return was clamped to `none` and the trash refused every
// photograph dragged to it.
//
// What this row does is destructive whatever the negotiated
// action is called — `dropped-on-trash` moves the file — so
// the name of the action was never what made it so, and
// insisting on `move` only cost the feature.
can-drop(ev) => { return ev.proposed-action; }
dropped(ev) => {
root.dropped-on-trash();
return ev.proposed-action;
}
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Icon {
name: "trash";
ink: root.trash-count > 0 ? Theme.warn-ink : Theme.ink-faint;
size: 12px;
y: (parent.height - self.height) / 2;
}
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(); }
}
// TRACES: FR-CAT-11a
// GESTURE: Review duplicate originals
// where: Collections sidebar
// touch: Tap "Duplicate originals" under the trash
// pointer: Click "Duplicate originals" under the trash
// why: Under the trash because the trash is where the spare
// copies go, and only while the catalog holds any: a row
// that is always there and usually empty is noise.
// manual: duplicate-originals
if Duplicates.group-count > 0: Rectangle {
height: Theme.row-height;
background: dup-touch.has-hover ? Theme.hover : transparent;
border-radius: Theme.radius-sm;
accessible-role: button;
accessible-label: "Duplicate originals, " + Duplicates.group-count;
accessible-action-default => { Duplicates.open(); }
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: "Duplicate originals";
color: Theme.ink-dim;
font-size: Theme.text;
vertical-alignment: center;
horizontal-stretch: 1;
}
Text {
text: Duplicates.group-count;
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
dup-touch := TouchArea {
clicked => { Duplicates.open(); }
}
}
// --- 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;
}
// The foot of the panel, under everything the current library holds,
// because it is the one control here that replaces all of it.
//
// It was in the library header, wedged between Sync and Rescan — two
// buttons that act on the library you already have. Reading "Change
// library" as one of that group is a way to lose a scan by aiming
// badly. Down here it is the last thing in the panel and the only
// thing below the tree it would replace.
Rectangle { height: 1px; background: Theme.rule; }
Button {
text: "Change library";
enabled: !root.scanning;
clicked => { root.change-library(); }
}
}
// Right edge, separating the panel from the grid.
Rectangle {
x: parent.width - 1px;
width: 1px;
background: Theme.rule;
}
}
// TRACES: FR-NC-6a | FR-NC-6c | FR-UI-3
// What a held collection asks: keep these photographs on this device, or give
// the disk back.
//
// A question rather than a toggle, because the two answers are not symmetrical
// conveniences — one commits the device to downloading gigabytes over whatever
// connection it has, and the other deletes gigabytes it already holds. Neither
// is something to discover by having done it.
//
// The sizes are in the labels rather than in a second confirmation, the same
// way the grid's "Export 40" puts its count in the button: a number read before
// the tap is worth more than a dialogue asking the same question after it.
//
// Drawn over the whole window rather than inside the sidebar. The sidebar is
// 232px wide and collapses entirely in the compact layout — a dialogue that
// lived in there would be unreachable on the device this gesture exists for.
export component OfflinePrompt inherits Rectangle {
/// The collection being asked about. Empty closes the prompt — a single
/// source for "is this open", rather than a bool that can disagree with the
/// name beside it.
in property <string> title;
/// What is in there and how much of it is already here, in one line.
in property <string> detail;
/// The affirmative label, with the transfer it commits to: Rust knows the
/// count and the bytes, and this file should not be doing arithmetic on
/// them.
in property <string> keep-label;
/// The destructive label, with the disk it frees.
in property <string> release-label;
/// Whether there is anything left to download, and anything held to delete.
/// Both can be false — an empty collection — and the prompt then says only
/// what it holds and offers the way out.
in property <bool> can-keep: false;
in property <bool> can-release: false;
/// Whether a download is already running for this collection, so the
/// affirmative reads as "already going" rather than inviting a second one.
in property <bool> busy: false;
callback keep();
callback release();
callback dismiss();
visible: root.title != "";
background: #000000CC;
// Swallows everything that misses the card, and dismisses. Declared before
// the card so the card's own controls sit above it.
TouchArea {
clicked => { root.dismiss(); }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: card.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
// A press on the card must not reach the scrim behind it and close the
// thing being read.
TouchArea { }
card := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "Keep offline";
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.title;
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
Text {
text: root.detail;
color: Theme.ink-dim;
font-size: Theme.text;
wrap: word-wrap;
}
Rectangle { height: 1px; background: Theme.rule; }
// Stacked rather than a row of three. At 420px on a tablet the
// labels carry counts and byte sizes, and three of those side by
// side elide to "Keep 128 pho…" — which loses exactly the number
// the label exists to show.
Button {
text: root.busy ? "Downloading…" : root.keep-label;
primary: true;
enabled: root.can-keep && !root.busy;
clicked => { root.keep(); }
}
if root.can-release: Button {
text: root.release-label;
clicked => { root.release(); }
}
Button {
text: "Cancel";
clicked => { root.dismiss(); }
}
}
}
}
// The actions on one collection row: rename, nest a new one inside, take it
// back out to the top level, delete it.
//
// This replaced a right-click that *deleted an empty collection outright* and
// declined with an error message otherwise. Two things were wrong with that,
// and they pull in opposite directions: the safe half was undiscoverable — no
// affordance says a row can be deleted, and the one gesture that could was
// destructive with no confirmation — while the unsafe half was unreachable, so
// a collection that held anything could not be deleted at all without first
// emptying it by hand, child by child.
//
// Drawn over the whole window rather than as a popup anchored to the row, for
// the reason `OfflinePrompt` above gives: the sidebar is 232px wide and closes
// entirely in the compact layout, so a menu living inside it would be
// unreachable on the device the long-press exists for.
export component CollectionMenu inherits Rectangle {
/// The collection being acted on. Empty closes the menu — one source for
/// "is this open", rather than a bool that can disagree with the name
/// beside it.
in property <string> title;
/// What is in there — "12 photographs · 2 collections inside". Composed in
/// Rust, which has the counts; this file does no arithmetic on them.
in property <string> detail;
/// Whether this collection has a parent, and so has somewhere to be moved
/// out to. Hidden rather than disabled at the top level: "move to top
/// level" on something already there is not a refusal worth explaining.
in property <bool> nested: false;
/// A saved filter, whose membership is its selector. It can still be
/// renamed, moved and deleted; what it cannot do is take images, so
/// nesting a new collection inside one would build a container whose
/// contents can never reach it.
in property <bool> smart: false;
/// Whether the delete has been asked once and is waiting to be confirmed.
/// Owned by Rust, which decides from the counts whether a confirmation is
/// needed at all — an empty collection is deleted on the first press.
in property <bool> confirming: false;
/// What deleting would actually do, spelled out. The photographs survive,
/// and a user who reads "Delete" as "delete my pictures" will not use this
/// feature again.
in property <string> confirm-detail;
/// The destructive label, with what it takes with it.
in property <string> delete-label: "Delete";
/// TRACES: FR-NC-6a
/// Whether this collection is being kept on the device, for the label on
/// the offline item.
in property <bool> pinned: false;
callback rename();
callback new-child();
/// Reparent to the top level — the un-nesting that had no gesture at all.
/// Nesting has one (drag a row onto another); its inverse had none, so a
/// collection put inside another was there permanently.
callback promote();
/// TRACES: FR-NC-6a
/// Open the offline question about this collection — the same one the tray
/// on the row opens.
callback offline();
callback delete();
callback dismiss();
visible: root.title != "";
background: #000000CC;
// Swallows everything that misses the card, and dismisses. Declared before
// the card so the card's own controls sit above it.
TouchArea {
clicked => { root.dismiss(); }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: card.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
// A press on the card must not reach the scrim behind it and close the
// thing being read.
TouchArea { }
card := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "COLLECTION";
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.title;
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
Text {
text: root.detail;
color: Theme.ink-dim;
font-size: Theme.text;
wrap: word-wrap;
}
Rectangle { height: 1px; background: Theme.rule; }
// The three non-destructive actions, hidden while a delete is
// waiting to be confirmed: a confirmation that leaves the other
// choices live is one mis-aimed press away from doing something
// else entirely, and the user is at that moment reading rather
// than aiming.
if !root.confirming: Button {
text: "Rename";
clicked => { root.rename(); }
}
if !root.confirming && !root.smart: Button {
text: "New collection inside";
clicked => { root.new-child(); }
}
if !root.confirming && root.nested: Button {
text: "Move to top level";
clicked => { root.promote(); }
}
// TRACES: FR-NC-6a | FR-UI-3
// The offline question, which the long press used to open on its
// own. It is here now because the press had to be given to this
// menu — there is one hold per row and the tree's other actions
// had no touch route at all without it.
//
// Nothing is lost by the move and something is gained: the tray on
// the row keeps its direct tap for the fast way in, and this is a
// full-width control rather than a 30px icon in a row shorter than
// the touch minimum, so the question now has a target that meets
// FR-UI-3 as well as a gesture that finds it.
if !root.confirming: Button {
text: root.pinned ? "Kept offline" : "Keep offline";
active: root.pinned;
clicked => { root.offline(); }
}
if !root.confirming: Rectangle { height: 1px; background: Theme.rule; }
// What the delete takes with it, in the words of the thing it
// takes. Only while confirming: shown always, it would be a
// warning about an action nobody has asked for yet, which is how a
// dialogue teaches people to stop reading it.
if root.confirming: Text {
text: root.confirm-detail;
color: Theme.warn-ink;
font-size: Theme.text;
wrap: word-wrap;
}
Button {
text: root.delete-label;
clicked => { root.delete(); }
}
Button {
text: "Cancel";
clicked => { root.dismiss(); }
}
}
}
}
// One collection the current selection is filed in.
export struct MembershipRow {
id: int,
name: string,
// How many of the selected photographs are in here, and how many were
// selected. Both, because "3" alone is ambiguous and "3 of 40" is not.
holding: int,
// Already formatted — "3 of 40", or just the count when one photograph is
// selected and the fraction would be noise.
detail: string,
}
// TRACES: FR-CAT-7 | FR-CAT-15
// The collections sidebar's own state: the tree, the row menu, renaming,
// drag and drop between rows, the trash row, and the membership sheet this
// file also declares.
//
// A global rather than properties and callbacks on the window root — see
// `session.slint` for why the develop panels stopped taking their wiring
// through the window root; the same reasoning applies here. What the sidebar
// shares with the library grid (a photograph dragged out of a cell, the
// selection a "file in collection" gesture acts on) stays on the window,
// because Rust on the library side still reaches it there.
export global Collections {
// --- 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;
in property <string> collection-scope-label: "";
in property <string> collection-error: "";
/// Which collection is being renamed in the sidebar, by id. 0 is none.
in property <int> collection-renaming: 0;
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
// --- the row menu the gesture above opens (FR-CAT-7) -------------------
//
// Rust owns every one of these. What the menu offers depends on counts it
// has to query — whether the collection is nested, what deleting would
// take — and the confirmation step is a policy decision, not a local bit
// the sheet can flip for itself.
/// The collection the menu is about. Empty closes it.
in property <string> collection-menu-title;
/// "12 photographs · 2 collections inside".
in property <string> collection-menu-detail;
/// Whether it has a parent, and so somewhere to be promoted out to.
in property <bool> collection-menu-nested: false;
/// Whether it is a saved filter, which cannot hold a nested collection.
in property <bool> collection-menu-smart: false;
/// Whether a delete has been asked once and awaits confirmation.
in property <bool> collection-menu-confirming: false;
/// What the delete would take with it, and what survives it.
in property <string> collection-menu-confirm-detail;
in property <string> collection-menu-delete-label;
/// TRACES: FR-NC-6a
/// Whether the collection the menu is about is kept on this device.
in property <bool> collection-menu-pinned: false;
callback collection-menu-rename();
callback collection-menu-new-child();
/// Move it back to the top level. The inverse of the drag that nests one,
/// which until now had no gesture at all.
callback collection-menu-promote();
/// TRACES: FR-NC-6a
/// Hand the offline question over to the handler the tray on the row
/// already uses, rather than a second route to the same prompt.
callback collection-menu-offline();
callback collection-menu-delete();
callback collection-menu-dismiss();
/// A row is held or released — the long press is timed in Rust.
callback collection-row-press(int, bool);
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
/// Which collection the hold has picked up, by id. 0 is none.
in property <int> collection-lifted: 0;
/// A row's drag crossed the threshold, or ended.
callback collection-drag-active(int, bool);
/// Whether "All photographs" would take the drag currently in flight.
in property <bool> collection-root-drop-allowed: false;
/// A collection was dropped on "All photographs": out to the top level.
callback collection-dropped-on-root();
/// TRACES: FR-UI-2 | FR-UI-4
/// 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);
/// Payload for dragging a collection onto another to reparent it.
pure callback collection-drag-payload(int) -> data-transfer;
// --- 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();
// --- where the selection is filed (FR-CAT-7) ---------------------------
//
// The library's "Remove from collection" can only ever mean the
// collection the grid is scoped to — it is the only one that button can
// name. This sheet is the other half: which collections the selection is
// actually in, and the way out of any of them without navigating there
// first. Opened from the library grid via the window's own
// `library-open-membership`, because only the library side knows the
// selection has changed enough to be worth recomputing against.
in property <bool> membership-open: false;
in property <[MembershipRow]> membership-rows;
callback membership-remove(int);
callback membership-dismiss();
}
// Where the selection is filed, and the way out of any of it.
//
// The library's "Remove from collection" button takes the selection out of the
// collection the *grid is scoped to*, which is the only one it can name. That
// leaves no way to take a photograph out of a collection without first
// navigating to that collection — and no way at all to find out which
// collections a photograph is in, since the grid badges a cell with a count
// and nothing else.
export component MembershipSheet inherits Rectangle {
/// Whether the sheet is up. A bool rather than a non-empty string as the
/// menu above uses, because this sheet is legitimately empty: a selection
/// filed nowhere is a real state, and it is worth saying so.
in property <bool> open: false;
/// How many photographs the answers are about.
in property <int> selected-count: 0;
/// The collections holding at least one of them, by name.
in property <[MembershipRow]> rows;
/// Take the selection out of this collection, by id.
callback remove(int);
callback dismiss();
visible: root.open;
background: #000000CC;
TouchArea {
clicked => { root.dismiss(); }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: min(card.preferred-height, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
TouchArea { }
card := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "FILED IN";
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.selected-count == 1 ? "1 photograph"
: root.selected-count + " photographs";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
}
// The empty case says what it means. A blank list under a heading
// reads as a sheet that failed to load.
if root.rows.length == 0: Text {
text: "Not in any collection. Drag onto a collection in the sidebar to file it.";
color: Theme.ink-dim;
font-size: Theme.text;
wrap: word-wrap;
}
if root.rows.length > 0: Rectangle { height: 1px; background: Theme.rule; }
if root.rows.length > 0: Flickable {
// A floor so one row is not squeezed out of existence, and a
// ceiling so a photograph filed in thirty collections does not
// push the Close button off the bottom of the window. The card
// is capped in height above; this is what keeps the control
// below the list inside that cap.
min-height: min(root.rows.length * (Theme.touch-target + 2px), 100px);
max-height: 260px;
viewport-height: root.rows.length * (Theme.touch-target + 2px);
for entry[i] in root.rows: HorizontalLayout {
y: i * (Theme.touch-target + 2px);
width: parent.width;
height: Theme.touch-target;
spacing: Theme.gap-sm;
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
Text {
text: entry.name;
color: Theme.ink;
font-size: Theme.text;
overflow: elide;
}
// Absent, not blank, with one photograph selected:
// "1 of 1" is arithmetic nobody asked for, and an
// empty line still takes its height.
if entry.detail != "": Text {
text: entry.detail;
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
}
// "Remove", not "Delete": this drops a membership row and
// the photographs stay in the library and in every other
// collection. The two words are one keystroke apart in the
// source and a catastrophe apart to the user.
Button {
text: "Remove";
y: (parent.height - self.height) / 2;
clicked => { root.remove(entry.id); }
}
}
}
Rectangle { height: 1px; background: Theme.rule; }
Button {
text: "Close";
clicked => { root.dismiss(); }
}
}
}
}