Every background job reported into a window property of its own — library-thumbs-done, library-pin-total, library-syncing — which only the grid ever read. A pin download that outlived the view it was started from drew nothing at all once the user opened an image, and there was no answer anywhere to "what is this busy with", because the answer was spread across eight properties nothing collected. They report to one register now (ui/dr-ui/src/activity.rs). It publishes an aggregate, which draws a three-pixel bar across the top of the shell in every view, and a row per job, which the settings page lists: scans, thumbnail batches, pin and open downloads, sidecar uploads, the sync and the trash. Failures stay on the list until they are cleared; routine successes do not, or a scroll would bury them. The handle removes a still-running job when it drops, so a worker that dies mid-transfer takes its row with it rather than leaving the bar sweeping for the rest of the session. Also carries in-flight work from a parallel session — the drawn icon set and the dr-pipeline ops split. dr-pipeline's build script does not compile at this commit; ui/dr-ui does, with clippy clean and its tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
567 lines
23 KiB
Plaintext
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, Icon } 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: 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); }
|
|
|
|
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 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;
|
|
|
|
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: 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;
|
|
|
|
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(); }
|
|
}
|
|
|
|
// --- 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;
|
|
}
|
|
}
|