An audit of every action by view against the keys the handlers bind left develop without zoom, pan, fit or a way back to the grid, the grid without select-none, thumbnail size or keywording, People with no key at all, and the export and copy sheets without Enter. It also found the reverse gap FR-UI-5 forbids: pick and reject had no route but P, X and U, and the 2026-09-19 amendment's judging in develop had not been built. Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid a mask. 0-5, P, X and U rate and flag the open photograph without moving on, with stars and Pick/Reject in the top bar as the pointer and touch route. = and - nudge the control last moved by a hundredth of its travel; the framing sliders, perspective included, now count as "last moved", so R puts them back as well. J turns the selected mask part's join chip. Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives pick and reject a pointer and touch route. Sidebar: Enter commits a collection's name, and Enter or Escape hands the keyboard back to the grid, where it used to go nowhere until something was clicked. People: Up and Down walk the rail, F2 puts the name field under the keys, and Escape or Back now leave the screen the way its back button does instead of doing nothing. Sheets: Enter does what the export or copy sheet's button does. The choices follow Lightroom where it has one. No new key steals typing: the grid's and People's keys live on focus holders that are not ancestors of any text field, and the sheets' Enter comes after a focused field has had it. Every binding is tagged beside its handler, and the gate added in the previous commit holds the two to each other.
1453 lines
63 KiB
Plaintext
1453 lines
63 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 { Keys } from "keys.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;
|
|
/// 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;
|
|
|
|
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 -----------------------------------------------------
|
|
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);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(); }
|
|
}
|
|
|
|
// --- 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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|