Select photographs with a finger, and take a collection with you
Two things a tablet could not do. Both existed for a pointer and had no touch form at all, which on Android meant the collection sidebar was somewhere to look at rather than somewhere to file into. **Selecting more than one.** Ctrl-click and shift-click are the only ways into a multi-selection, and touch has neither. Holding a cell now enters selection mode, where a tap toggles — reported to Rust as a ctrl-press, so it goes through the same `apply_press` as everything else rather than growing a second copy of the selection rules. A double tap takes the run between where selecting began and there: the touch form of shift-click, and the reason the anchor from *before* the double tap has to be remembered, since both of its taps move the anchor onto the cell being tapped. A "Select" button does the same thing where a gesture would go undiscovered (FR-UI-4). **Filing without a drag.** A one-finger drag beginning in the grid belongs to the Flickable that scrolls it — that is the arbitration working, not a bug to route around — so the selection can now be filed from a sheet listing the sidebar's own rows. Copy by default, as the drag has always been; moving out of the collection being shown is a switch, because it is the one that takes something away. **Taking a collection offline.** The machinery was there and reachable only by scoping the grid to a collection and finding a button behind a disclosure. Holding a collection's name now asks the question directly, and the tray on a row and the header button ask the same one — three affordances doing two different things is how a user comes to avoid all three. The question is asked rather than a toggle flipped because both answers are expensive: one downloads gigabytes, the other deletes them, and the counts and sizes go in the buttons where they are read before the tap. `Cache::release` is new and is the destructive half `unpin` deliberately is not. "Remove the local copies" is asked by someone whose device is full, and withdrawing a promise while leaving the bytes for a future eviction to notice is not an answer to it. It unpins before forgetting, or the next pin fetch would dutifully download everything it just deleted. The sidebar's trays read `tier_actual`, never `tier_desired`: the question is whether these will open on the aeroplane, and a pin whose download has not run yet answers no. TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+82
-1
@@ -3,7 +3,7 @@ import { AdjustPanel, GeometryPanel, ParamRow, TransferPanel } from "adjust.slin
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
|
||||
@@ -395,6 +395,20 @@ export component AppWindow inherits Window {
|
||||
in property <int> library-pin-total: 0;
|
||||
callback library-toggle-pin-scope();
|
||||
|
||||
// The question a held collection asks: keep these on the device, or give
|
||||
// the disk back. Driven entirely from Rust — the counts and the byte sizes
|
||||
// in the labels are catalog reads, and an empty `title` is what closes it.
|
||||
in property <string> offline-prompt-title: "";
|
||||
in property <string> offline-prompt-detail: "";
|
||||
in property <string> offline-prompt-keep-label: "";
|
||||
in property <string> offline-prompt-release-label: "";
|
||||
in property <bool> offline-prompt-can-keep: false;
|
||||
in property <bool> offline-prompt-can-release: false;
|
||||
in property <bool> offline-prompt-busy: false;
|
||||
callback offline-prompt-keep();
|
||||
callback offline-prompt-release();
|
||||
callback offline-prompt-dismiss();
|
||||
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
@@ -451,6 +465,27 @@ export component AppWindow inherits Window {
|
||||
callback collection-toggle(int);
|
||||
callback collection-new();
|
||||
callback collection-menu(int);
|
||||
/// TRACES: FR-NC-6a
|
||||
/// Ask about a collection's local copies: the tray on a row, or a long
|
||||
/// press anywhere on it.
|
||||
callback collection-offline-menu(int);
|
||||
/// A row is held or released — the long press is timed in Rust.
|
||||
callback collection-row-press(int, bool);
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// File the grid's selection in a collection without dragging it there:
|
||||
/// the target's id, and whether to take the images out of the collection
|
||||
/// currently being shown.
|
||||
callback library-file-in-collection(int, bool);
|
||||
/// TRACES: FR-UI-2
|
||||
/// Whether a tap in the grid selects rather than opens, and the button
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
in property <bool> library-select-mode: false;
|
||||
callback library-toggle-select-mode();
|
||||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||
callback library-cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on a cell: in selection mode, the far end of a range.
|
||||
callback library-cell-double-clicked(int);
|
||||
/// 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.
|
||||
@@ -753,6 +788,15 @@ in property <bool> panel-visible: true;
|
||||
// the inner scopes that use it for something more specific — a
|
||||
// rename in the sidebar — see it first and accept it there.
|
||||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||||
// A dialogue takes "go up one" first, and it is the *only*
|
||||
// thing that goes up. Without this, Android's Back button
|
||||
// would leave the library from behind an open question — the
|
||||
// view changing underneath a modal, which reads as the app
|
||||
// having lost its place.
|
||||
if (root.offline-prompt-title != "") {
|
||||
root.offline-prompt-dismiss();
|
||||
return accept;
|
||||
}
|
||||
return root.back-requested() ? accept : reject;
|
||||
}
|
||||
return reject;
|
||||
@@ -910,6 +954,8 @@ in property <bool> panel-visible: true;
|
||||
toggle(id) => { root.collection-toggle(id); }
|
||||
new-collection() => { root.collection-new(); }
|
||||
row-menu(id) => { root.collection-menu(id); }
|
||||
offline-menu(id) => { root.collection-offline-menu(id); }
|
||||
row-press(id, down) => { root.collection-row-press(id, down); }
|
||||
|
||||
renaming-id: root.collection-renaming;
|
||||
rename-start(id) => { root.collection-rename-start(id); }
|
||||
@@ -1009,6 +1055,16 @@ in property <bool> panel-visible: true;
|
||||
cell-pressed(i, ctrl, shift) => {
|
||||
root.library-cell-pressed(i, ctrl, shift);
|
||||
}
|
||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
||||
select-mode: root.library-select-mode;
|
||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||
// The sidebar's rows, not a second model: the sheet files
|
||||
// into the same tree the sidebar draws.
|
||||
collections: root.collection-rows;
|
||||
file-in-collection(id, moves) => {
|
||||
root.library-file-in-collection(id, moves);
|
||||
}
|
||||
cursor: root.library-cursor;
|
||||
move-cursor(delta, extend) => {
|
||||
root.library-move-cursor(delta, extend);
|
||||
@@ -1633,5 +1689,30 @@ in property <bool> panel-visible: true;
|
||||
indeterminate: !root.activity-determinate;
|
||||
fraction: root.activity-fraction;
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
// The offline question, over the shell rather than inside the sidebar
|
||||
// that raises it. The sidebar is 232px wide and closes entirely in the
|
||||
// compact layout, so a dialogue living in there would be unreadable on
|
||||
// the device the gesture exists for — and the answer commits gigabytes
|
||||
// either way, which deserves the width to say so.
|
||||
//
|
||||
// Below the load bar, above everything else: a download started from
|
||||
// here shows its progress in that bar, and the bar must not be the
|
||||
// thing the dialogue covers.
|
||||
OfflinePrompt {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
title: root.offline-prompt-title;
|
||||
detail: root.offline-prompt-detail;
|
||||
keep-label: root.offline-prompt-keep-label;
|
||||
release-label: root.offline-prompt-release-label;
|
||||
can-keep: root.offline-prompt-can-keep;
|
||||
can-release: root.offline-prompt-can-release;
|
||||
busy: root.offline-prompt-busy;
|
||||
keep() => { root.offline-prompt-keep(); }
|
||||
release() => { root.offline-prompt-release(); }
|
||||
dismiss() => { root.offline-prompt-dismiss(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,17 @@ export struct CollectionRow {
|
||||
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.
|
||||
@@ -70,6 +81,17 @@ component TreeRow inherits Rectangle {
|
||||
/// 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.
|
||||
@@ -224,6 +246,54 @@ component TreeRow inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -253,6 +323,21 @@ component TreeRow inherits Rectangle {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +362,13 @@ export component CollectionsPanel inherits Rectangle {
|
||||
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.
|
||||
///
|
||||
@@ -423,6 +515,8 @@ export component CollectionsPanel inherits Rectangle {
|
||||
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); }
|
||||
@@ -564,3 +658,121 @@ export component CollectionsPanel inherits Rectangle {
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ component Ink inherits Path {
|
||||
// chevron-right chevron-down chevron-left arrow-up
|
||||
// rotate-ccw rotate-cw flip-h flip-v
|
||||
// collection collection-smart
|
||||
// offline offline-held
|
||||
export component Icon inherits Rectangle {
|
||||
in property <string> name;
|
||||
/// The single colour the whole drawing takes. Named `ink` rather than
|
||||
@@ -253,4 +254,35 @@ export component Icon inherits Rectangle {
|
||||
stroke-width: root.weight;
|
||||
commands: "M 12 9 L 15 12 L 12 15 L 9 12 Z";
|
||||
}
|
||||
|
||||
// --- availability ------------------------------------------------------
|
||||
//
|
||||
// TRACES: FR-NC-6a | FR-NC-6c
|
||||
// "Kept on this device": an arrow coming down into a tray. The download
|
||||
// shape rather than a pin, because what the gesture does is *fetch* — a
|
||||
// pin reads as "stick this to the top of the list", which is what it means
|
||||
// in every other sidebar the user has seen.
|
||||
//
|
||||
// Outline and solid are the same tray with the same arrow, so a collection
|
||||
// that finishes downloading does not shift under the finger that started
|
||||
// it. Solid fills the tray only: an arrow filled as well loses the shaft
|
||||
// at 12px and reads as a blob.
|
||||
|
||||
if root.name == "offline": Ink {
|
||||
stroke: root.ink;
|
||||
stroke-width: root.weight;
|
||||
commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4 "
|
||||
+ "M 4.2 15.4 L 4.2 19.8 L 19.8 19.8 L 19.8 15.4";
|
||||
}
|
||||
|
||||
if root.name == "offline-held": Ink {
|
||||
stroke: root.ink;
|
||||
stroke-width: root.weight;
|
||||
commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4";
|
||||
}
|
||||
if root.name == "offline-held": Ink {
|
||||
fill: root.ink;
|
||||
stroke-width: root.weight;
|
||||
commands: "M 4.2 15.4 L 19.8 15.4 L 19.8 19.8 L 4.2 19.8 Z";
|
||||
}
|
||||
}
|
||||
|
||||
+301
-3
@@ -10,6 +10,10 @@
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint";
|
||||
// The filing sheet lists the same rows the sidebar draws, from the same model:
|
||||
// two lists of collections that could disagree about what exists is one list
|
||||
// too many.
|
||||
import { CollectionRow } from "collections.slint";
|
||||
|
||||
// One bar of the capture-time histogram.
|
||||
export struct TimelineBar {
|
||||
@@ -449,6 +453,11 @@ export component FlagMark inherits Rectangle {
|
||||
component HeaderActions inherits HorizontalLayout {
|
||||
in property <string> scope-label;
|
||||
in property <int> selected-count: 0;
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Whether taps are selecting rather than opening. The non-gesture half of
|
||||
/// touch multi-selection: the long press is the quick way in, and this is
|
||||
/// the way that can be *found*.
|
||||
in property <bool> select-mode: false;
|
||||
in property <bool> scanning: false;
|
||||
in property <bool> syncing: false;
|
||||
in property <bool> scope-pinned: false;
|
||||
@@ -469,6 +478,9 @@ component HeaderActions inherits HorizontalLayout {
|
||||
callback cancel-export();
|
||||
callback paste-settings-to-selection();
|
||||
callback remove-from-collection();
|
||||
/// Open the sheet that files the selection in a collection.
|
||||
callback add-to-collection();
|
||||
callback toggle-select-mode();
|
||||
callback change-library();
|
||||
callback toggle-pin-scope();
|
||||
callback sync-now();
|
||||
@@ -477,6 +489,35 @@ component HeaderActions inherits HorizontalLayout {
|
||||
|
||||
spacing: Theme.gap;
|
||||
|
||||
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
|
||||
// Selection mode, first in the row because it is what the rest of the row
|
||||
// acts on: every button after this one appears once something is selected.
|
||||
//
|
||||
// It exists at all because the desktop's way in — ctrl-click, shift-click —
|
||||
// has no touch equivalent, and the drag that files a photograph in a
|
||||
// collection needs a *selection* before it can carry more than one. On a
|
||||
// tablet the long press does this faster; a gesture with no visible
|
||||
// counterpart is a feature only its author knows about (FR-UI-4).
|
||||
//
|
||||
// "Done" rather than "Selecting": the label should say what pressing it
|
||||
// does, and the inverted fill already says which state we are in.
|
||||
Button {
|
||||
text: root.select-mode ? "Done" : "Select";
|
||||
active: root.select-mode;
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => { root.toggle-select-mode(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-7 | FR-UI-4
|
||||
// File the selection in a collection without dragging it there. The drag
|
||||
// is the faster gesture with a pointer and impossible with one finger on a
|
||||
// grid that scrolls, which is the whole reason this button exists.
|
||||
if root.selected-count > 0: Button {
|
||||
text: "Add to collection";
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => { root.add-to-collection(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Batch-apply the copied settings. Shown only with both a selection and a
|
||||
// clipboard, because it is meaningless without either — and because a
|
||||
@@ -544,9 +585,13 @@ component HeaderActions inherits HorizontalLayout {
|
||||
// request, and a button that meant either depending on invisible state
|
||||
// would be a trap.
|
||||
if root.scope-label != "": Button {
|
||||
// No tick on the pinned label: `active` already inverts the button,
|
||||
// No tick on the kept label: `active` already inverts the button,
|
||||
// which says the same thing without a symbol inside a string.
|
||||
text: root.scope-pinned ? "Pinned" : "Pin offline";
|
||||
//
|
||||
// It opens the same question the sidebar's tray and a held row open,
|
||||
// rather than pinning outright — see the note on the callback in
|
||||
// `library_ui::wire`.
|
||||
text: root.scope-pinned ? "Kept offline" : "Keep offline";
|
||||
active: root.scope-pinned;
|
||||
enabled: !root.scanning;
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
@@ -718,6 +763,29 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// Modifier state at press time, so Rust can decide replace / add / extend
|
||||
/// without the .slint file encoding the selection policy.
|
||||
callback cell-pressed(int, bool, bool);
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
||||
/// the finger travelled. Cancels the long-press timer that would otherwise
|
||||
/// turn a scroll into a selection. Rust owns that timer: Slint has no
|
||||
/// long-press gesture, and a hand-rolled one here would need a `Timer` per
|
||||
/// visible cell.
|
||||
callback cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on the same cell. In selection mode this is the touch form of
|
||||
/// shift-click: everything from where the selection started to here. Rust
|
||||
/// decides — outside that mode a double tap is two ordinary clicks and the
|
||||
/// first has already opened the image.
|
||||
callback cell-double-clicked(int);
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Whether a tap selects rather than opens.
|
||||
///
|
||||
/// Held in Rust beside the selection it modifies, so the long press and the
|
||||
/// header's button are two doors into one state rather than two states that
|
||||
/// can disagree. In this mode a plain tap toggles a cell — exactly what
|
||||
/// ctrl-click does with a pointer, which is why the press below passes it
|
||||
/// as the ctrl flag rather than as a third selection policy.
|
||||
in property <bool> select-mode: false;
|
||||
callback toggle-select-mode();
|
||||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||||
/// drag starts, so it always reflects the selection as it is at that moment.
|
||||
pure callback drag-payload() -> data-transfer;
|
||||
@@ -828,6 +896,31 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// thing this button does.
|
||||
callback remove-from-collection();
|
||||
|
||||
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
|
||||
//
|
||||
// The drag onto the sidebar is the fast way to file photographs, and it is
|
||||
// a pointer gesture: a one-finger drag beginning inside the grid belongs to
|
||||
// the Flickable that scrolls it, which is the arbitration described at the
|
||||
// top of `collections_ui.rs` working exactly as it should. So touch needs a
|
||||
// way in that is not a drag, and that is this sheet.
|
||||
//
|
||||
// The rows are the sidebar's own model, passed through rather than queried
|
||||
// again: two lists of collections is one list too many, and the one that
|
||||
// goes stale is always the one nobody is looking at.
|
||||
in property <[CollectionRow]> collections;
|
||||
/// Whether the sheet is up. Local, because it is a disclosure rather than a
|
||||
/// preference — nothing outside this file needs to know it is open, and
|
||||
/// what closes it is choosing a collection or dismissing it.
|
||||
property <bool> filing: false;
|
||||
/// Whether the images should *leave* the collection being shown, rather
|
||||
/// than being filed in a second one as well. Only meaningful while scoped,
|
||||
/// and reset every time the sheet opens: a destructive default that
|
||||
/// remembered itself between uses is how photographs go missing.
|
||||
property <bool> filing-moves: false;
|
||||
/// File the selection: the target collection's id, and whether to take it
|
||||
/// out of the one currently being shown.
|
||||
callback file-in-collection(int, bool);
|
||||
|
||||
// Cell geometry. Columns are derived from the available width so the grid
|
||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||||
@@ -943,6 +1036,17 @@ export component LibraryGrid inherits Rectangle {
|
||||
cancel-export => { root.cancel-export(); }
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
select-mode: root.select-mode;
|
||||
toggle-select-mode => { root.toggle-select-mode(); }
|
||||
add-to-collection => {
|
||||
// Reset every time it opens: see `filing-moves`.
|
||||
root.filing-moves = false;
|
||||
root.filing = true;
|
||||
// The disclosure has served its purpose and would
|
||||
// otherwise sit behind the sheet, so the row it belongs
|
||||
// to is still there when the sheet closes.
|
||||
root.actions-open = false;
|
||||
}
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
sync-now => { root.sync-now(); }
|
||||
@@ -994,6 +1098,17 @@ export component LibraryGrid inherits Rectangle {
|
||||
cancel-export => { root.cancel-export(); }
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
select-mode: root.select-mode;
|
||||
toggle-select-mode => { root.toggle-select-mode(); }
|
||||
add-to-collection => {
|
||||
// Reset every time it opens: see `filing-moves`.
|
||||
root.filing-moves = false;
|
||||
root.filing = true;
|
||||
// The disclosure has served its purpose and would
|
||||
// otherwise sit behind the sheet, so the row it belongs
|
||||
// to is still there when the sheet closes.
|
||||
root.actions-open = false;
|
||||
}
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
sync-now => { root.sync-now(); }
|
||||
@@ -1300,6 +1415,23 @@ export component LibraryGrid inherits Rectangle {
|
||||
init => { self.focus(); }
|
||||
|
||||
key-pressed(event) => {
|
||||
// TRACES: FR-UI-2 | FR-UI-4
|
||||
// Back and Escape close what is open here, innermost
|
||||
// first, before the shell above gets to read them as
|
||||
// "leave the library". On Android that is the system Back
|
||||
// button, and a sheet it walked straight past would leave
|
||||
// the user out of the grid with their selection gone.
|
||||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||||
if (root.filing) {
|
||||
root.filing = false;
|
||||
return accept;
|
||||
}
|
||||
if (root.select-mode) {
|
||||
root.toggle-select-mode();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
if (event.text == "0") { root.judged(0, -1); return accept; }
|
||||
if (event.text == "1") { root.judged(1, -1); return accept; }
|
||||
if (event.text == "2") { root.judged(2, -1); return accept; }
|
||||
@@ -1678,14 +1810,30 @@ export component LibraryGrid inherits Rectangle {
|
||||
// Selected on *press*, not on release: the drag that may
|
||||
// follow reads the selection to build its payload, and by
|
||||
// release the pointer is over the sidebar.
|
||||
//
|
||||
// In selection mode the press is reported as though ctrl
|
||||
// were held. That is not a shortcut: toggling one cell
|
||||
// while keeping the rest *is* what ctrl-click means, and
|
||||
// giving touch its own policy would be a second copy of
|
||||
// the rules in `collections_ui::apply_press` to keep in
|
||||
// step with the first.
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
root.cell-pressed(
|
||||
i,
|
||||
ev.modifiers.control,
|
||||
ev.modifiers.control || root.select-mode,
|
||||
ev.modifiers.shift,
|
||||
);
|
||||
}
|
||||
// Both endings, and `cancel` is the important one:
|
||||
// the Flickable takes the pointer as soon as the
|
||||
// finger travels, so without this a scroll that
|
||||
// began on a cell would come to rest as a long
|
||||
// press and select it.
|
||||
if (ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel) {
|
||||
root.cell-press-ended();
|
||||
}
|
||||
}
|
||||
|
||||
// A *plain* click opens the image; a modified one is purely
|
||||
@@ -1695,6 +1843,11 @@ export component LibraryGrid inherits Rectangle {
|
||||
// records it and Rust decides — `cell-clicked` is only
|
||||
// honoured when the press was unmodified.
|
||||
clicked => { root.cell-clicked(i); }
|
||||
// The far end of a range, in selection mode. Slint
|
||||
// delivers `clicked` for the first tap as well, which
|
||||
// is why the toggling is idempotent-by-union in Rust
|
||||
// rather than this file trying to swallow one of them.
|
||||
double-clicked => { root.cell-double-clicked(i); }
|
||||
}
|
||||
|
||||
// --- the rating strip, ABOVE the cell's own hit area ---
|
||||
@@ -1735,4 +1888,149 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// --- the filing sheet (FR-CAT-7, FR-UI-4) -------------------------------
|
||||
//
|
||||
// "Put these in…", for the times a drag is not available: one finger on a
|
||||
// scrolling grid, or a selection made across a scrub where the sidebar has
|
||||
// long since been closed to give the photographs the width.
|
||||
//
|
||||
// Last in the file, so it draws over the grid — and outside the
|
||||
// VerticalLayout above, so appearing does not reflow the header and the
|
||||
// cells underneath it.
|
||||
if root.filing: Rectangle {
|
||||
background: #000000CC;
|
||||
|
||||
// Swallows the taps that miss the card, and closes. First, so the
|
||||
// card's own controls sit above it.
|
||||
TouchArea {
|
||||
clicked => { root.filing = false; }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||
// Tall enough for the list, but never taller than the window: a
|
||||
// library with forty collections must still leave the buttons on
|
||||
// screen, which is what the Flickable inside is for.
|
||||
height: min(sheet.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;
|
||||
|
||||
// Stops a press on the card reaching the scrim behind it.
|
||||
TouchArea { }
|
||||
|
||||
sheet := VerticalLayout {
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.selected-count == 1
|
||||
? "File 1 photograph in…"
|
||||
: "File " + root.selected-count + " photographs in…";
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-lg;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// Filing is a *copy*: a photograph can be in as many
|
||||
// collections as it belongs in, which is what a join table
|
||||
// means and what the drag has always done. Moving is the
|
||||
// exception and has to be asked for, because it is the one
|
||||
// that takes something away.
|
||||
if root.scope-label != "": Button {
|
||||
text: root.filing-moves
|
||||
? "Moving out of " + root.scope-label
|
||||
: "Also keep in " + root.scope-label;
|
||||
active: root.filing-moves;
|
||||
clicked => { root.filing-moves = !root.filing-moves; }
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
// A floor, so the list is not squeezed out of existence by
|
||||
// the buttons around it on a short window.
|
||||
min-height: 120px;
|
||||
viewport-height: root.collections.length * (Theme.touch-target + 2px);
|
||||
|
||||
for row[i] in root.collections: Rectangle {
|
||||
y: i * (Theme.touch-target + 2px);
|
||||
width: parent.width;
|
||||
// A full touch target per row, where the sidebar's
|
||||
// equivalent is 26px. The sidebar is a place to look;
|
||||
// this is a place to hit once, with a thumb, holding a
|
||||
// selection that took a minute to build (FR-UI-3).
|
||||
height: Theme.touch-target;
|
||||
background: row-touch.pressed ? Theme.pressed
|
||||
: (row-touch.has-hover ? Theme.hover : transparent);
|
||||
border-radius: Theme.radius-sm;
|
||||
// A saved filter's membership is its selector, so it
|
||||
// cannot be filed into — the same refusal the sidebar
|
||||
// makes on a drag, made here before the tap rather
|
||||
// than after it.
|
||||
opacity: row.smart ? 0.4 : 1.0;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm + row.depth * Theme.indent;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Icon {
|
||||
name: row.smart ? "collection-smart" : "collection";
|
||||
ink: Theme.ink-faint;
|
||||
size: 14px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: row.name;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: row.smart ? "computed"
|
||||
: (row.deep-count > 0 ? row.deep-count + "" : "");
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
row-touch := TouchArea {
|
||||
enabled: !row.smart;
|
||||
clicked => {
|
||||
root.file-in-collection(row.id, root.filing-moves);
|
||||
root.filing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.collections.length == 0: Text {
|
||||
text: "No collections yet. Make one with + in the sidebar.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
width: parent.width;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
Button {
|
||||
text: "Cancel";
|
||||
clicked => { root.filing = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user