"Keep offline" and "Change library" were buttons in the library header, in a row that is otherwise entirely library-wide. Both refer to the tree instead. "Keep offline" could only ever mean the scoped collection, while sitting nowhere near the tree that says which that is — and while the sidebar already offered the same question twice, on each row's tray and on a held row. It now sits under the tree, in the panel whose selection decides what it acts on, in the same place and shape the trash already gives Restore and Empty. It stays a labelled control rather than being dropped for the tray: a 26px row's tray is a small thing to hit, and "Kept offline" spelt out for the collection you are looking at is the discoverable version. "Change library" was wedged between Sync and Rescan, two buttons that act on the library you already have. Reading it as one of that group is a way to lose a scan by aiming badly. It is now the last thing in the panel, under the tree it replaces wholesale, behind a rule. On a tablet both are one tap further away, behind the sidebar toggle that leads the header. That is the panel a user is already in when they scope the grid to a collection, so it is where they are when either of these becomes the thing they want. The grid keeps `pin-done`/`pin-total` and its progress bar: the transfer is worth reporting wherever it was started from, including a row the grid is not scoped to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
864 lines
36 KiB
Plaintext
864 lines
36 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: "";
|
|
/// 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);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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%;
|
|
// 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;
|
|
}
|
|
|
|
// 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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|