The tree could be built nested but never rearranged: `set_parent` existed, with its cycle check and its tests, and nothing in the UI called it. A collection created in the wrong place stayed there. Each row is already a drop target, so it becomes a `DragArea` too — wrapped at the instantiation site the way the grid's cells are, which keeps the row's own TouchArea nested underneath and a click still selecting. `allow-move`, not copy: a collection has one parent, unlike a photograph, which is filed in as many collections as you like. The drop is handed only the target's id, so the source is remembered from the press that precedes the drag — Slint builds the payload through a `pure` binding, which must not have side effects. An image drag always fills `dragging`, so an empty payload with a remembered row is unambiguously a rearrangement; the row is taken rather than read, or a later empty drop would move a collection nobody touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
799 lines
33 KiB
Plaintext
799 lines
33 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,
|
|
// 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;
|
|
|
|
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();
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
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: "";
|
|
|
|
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);
|
|
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();
|
|
|
|
// --- 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);
|
|
|
|
// 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);
|
|
|
|
TreeRow {
|
|
width: 100%;
|
|
height: 100%;
|
|
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); }
|
|
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(); }
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|