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>
2037 lines
96 KiB
Plaintext
2037 lines
96 KiB
Plaintext
// The library grid: what a scanned library looks like.
|
||
//
|
||
// Cells come from Rust as a windowed model, never the whole catalog — a 17k
|
||
// image library must not become 17k live elements (FR-CAT-4).
|
||
//
|
||
// A cell with no thumbnail yet shows why rather than an empty box. On a remote
|
||
// library a thumbnail is a network round trip, so "nothing there", "not
|
||
// fetched yet", and "no preview in this file" are three different states and
|
||
// must not look identical (FR-NC-6c).
|
||
|
||
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 {
|
||
// 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet
|
||
// day stays visible beside a wedding.
|
||
height: float,
|
||
// Bucket start, Unix seconds. What a click on this bar scrubs to.
|
||
start: int,
|
||
count: int,
|
||
label: string,
|
||
// "2024", "Mar", or empty. Non-empty only where this bucket begins a new
|
||
// year or month, so the axis is labelled at boundaries rather than on
|
||
// every bar. Chosen in Rust, which knows the granularity.
|
||
period-label: string,
|
||
}
|
||
|
||
// The capture-time sidebar: the shape of the library over time, and the
|
||
// primary way of moving through it.
|
||
//
|
||
// **Vertical, and a sidebar rather than a strip**, because a scroll position is
|
||
// a relative quantity and a date is an absolute one. The grid's scrollbar says
|
||
// how far through you are; this says *when* you are.
|
||
//
|
||
// Three gestures, in the darktable idiom:
|
||
//
|
||
// - **click or drag** — scrub the grid to that instant
|
||
// - **drag with the middle button, or shift-drag** — pan the visible span
|
||
// - **wheel** — zoom, which changes the bucket size Rust picks
|
||
//
|
||
// # One hit area, not one per bar
|
||
//
|
||
// An earlier version gave every bar its own `TouchArea`. With a few hundred
|
||
// buckets that is a few hundred overlapping hit regions, and a drag is
|
||
// delivered to whichever bar the press *started* on rather than the one under
|
||
// the cursor — so scrubbing jumped and stuttered. There is exactly one
|
||
// `TouchArea` here and the bucket is computed from the pointer's position.
|
||
export component Timeline inherits Rectangle {
|
||
in property <[TimelineBar]> bars;
|
||
in property <string> range-label;
|
||
/// The bucket the grid is currently showing, highlighted so the position is
|
||
/// visible when the grid is moved by scrolling instead.
|
||
in property <int> current-start: 0;
|
||
/// Where the grid sits along the visible span, 0..1, supplied by Rust.
|
||
///
|
||
/// **A fraction, not a bucket index.** The marker has to be placed by the
|
||
/// same quantity a click produces: `fraction-at` maps a y to a fraction of
|
||
/// the track and Rust interpolates an instant from it, so positioning the
|
||
/// marker from a bar index instead put it wherever that bucket's *slot*
|
||
/// happened to fall — never under the pointer. Negative means "not
|
||
/// anchored".
|
||
in property <float> current-fraction: -1;
|
||
/// True once the user has taken control. Until then the marker rests at the
|
||
/// middle rather than pinning to either end, which would imply a selection
|
||
/// that has not been made.
|
||
in property <bool> anchored: false;
|
||
|
||
/// Scrub to a fraction along the visible span. Rust turns it into an
|
||
/// instant, interpolating within a bucket rather than snapping to its edge.
|
||
callback scrub-to(float);
|
||
callback pan(float);
|
||
callback zoom(int);
|
||
/// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches
|
||
/// them together. Continuous, unlike the wheel's discrete steps.
|
||
callback pinch(float);
|
||
|
||
width: 96px;
|
||
background: Theme.surface;
|
||
|
||
property <int> hovered: -1;
|
||
property <length> track-top: 22px;
|
||
property <length> track-height: max(1px, self.height - self.track-top - 6px);
|
||
property <length> slot: root.track-height / max(1, root.bars.length);
|
||
|
||
/// Bucket index under a y coordinate, clamped to the ends so a drag that
|
||
/// leaves the widget still scrubs to the nearest bucket rather than
|
||
/// stopping dead.
|
||
function bucket-at(y: length) -> int {
|
||
return clamp(floor((y - root.track-top) / max(1px, root.slot)),
|
||
0, max(0, root.bars.length - 1));
|
||
}
|
||
|
||
/// Position along the axis as a fraction, 0 at the first bucket's start and
|
||
/// 1 at the last one's end.
|
||
///
|
||
/// **Fractional rather than a bucket index.** Snapping to whole buckets
|
||
/// makes a slow drag feel dead — the pointer moves and nothing happens
|
||
/// until it crosses a boundary, then the grid jumps a whole month. Rust
|
||
/// interpolates an instant from this, so the grid tracks the finger.
|
||
function fraction-at(y: length) -> float {
|
||
return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0);
|
||
}
|
||
|
||
// Header: the hovered bucket, else the whole span.
|
||
Caption {
|
||
x: 8px;
|
||
y: 4px;
|
||
width: parent.width - 16px;
|
||
text: root.hovered >= 0 && root.hovered < root.bars.length
|
||
? root.bars[root.hovered].label + " · " + root.bars[root.hovered].count
|
||
: root.range-label;
|
||
emphasised: root.hovered >= 0;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The bars. Purely visual — every gesture is handled by the single
|
||
// TouchArea below, which sits above them.
|
||
for bar[i] in root.bars: Rectangle {
|
||
// Bars grow from the *left* edge, so the axis reads like a timeline
|
||
// turned on its side and the labels have room on the right.
|
||
x: 0;
|
||
y: root.track-top + i * root.slot;
|
||
width: max(1px, (parent.width - 34px) * bar.height);
|
||
height: max(1px, root.slot - 1px);
|
||
background: bar.start == root.current-start
|
||
? Theme.active
|
||
: (root.hovered == i ? Theme.ink-dim : Theme.ink-faint);
|
||
}
|
||
|
||
// Year and month labels down the right-hand edge.
|
||
//
|
||
// Drawn only where a bar *starts* a new period, so a month-bucketed view
|
||
// labels each January rather than repeating the year on every bar. The
|
||
// label text itself is chosen in Rust, which knows the granularity; an
|
||
// empty string means "no boundary here".
|
||
for bar[i] in root.bars: Text {
|
||
x: parent.width - 32px;
|
||
y: root.track-top + i * root.slot - 5px;
|
||
width: 30px;
|
||
text: bar.period-label;
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
visible: bar.period-label != "";
|
||
}
|
||
|
||
// Where the grid currently sits. Rests at the midpoint until the user has
|
||
// actually chosen a position.
|
||
Rectangle {
|
||
x: 0;
|
||
width: parent.width;
|
||
height: 1px;
|
||
background: Theme.active;
|
||
opacity: root.anchored ? 1.0 : 0.35;
|
||
y: root.anchored && root.current-fraction >= 0
|
||
? root.track-top + clamp(root.current-fraction, 0.0, 1.0) * root.track-height
|
||
: root.track-top + root.track-height / 2;
|
||
}
|
||
|
||
// The single hit area. Everything above is inert.
|
||
// Two-finger pinch, for tablet. There is no wheel there, so without this
|
||
// the axis could only be zoomed by a control a finger cannot reach.
|
||
//
|
||
// `scale` is cumulative from 1.0 for the whole gesture, so the delta since
|
||
// the last update is what maps onto a zoom step — otherwise a slow spread
|
||
// would apply its total repeatedly and shoot straight to full zoom.
|
||
pinch := ScaleRotateGestureHandler {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => { self.last-scale = 1.0; }
|
||
updated => {
|
||
root.pinch(self.scale / max(0.01, self.last-scale));
|
||
self.last-scale = self.scale;
|
||
}
|
||
ended => { self.last-scale = 1.0; }
|
||
cancelled => { self.last-scale = 1.0; }
|
||
}
|
||
|
||
touch := TouchArea {
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: pointer;
|
||
|
||
property <length> press-y;
|
||
property <bool> panning;
|
||
|
||
moved => {
|
||
if (self.panning) {
|
||
// Fractional, so a slow drag moves the view continuously
|
||
// rather than sitting still until it crosses a bucket edge.
|
||
root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height));
|
||
self.press-y = self.mouse-y;
|
||
} else if (self.pressed && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
root.hovered = root.bars.length > 0 ? root.bucket-at(self.mouse-y) : -1;
|
||
}
|
||
|
||
pointer-event(e) => {
|
||
if (e.kind == PointerEventKind.down) {
|
||
self.press-y = self.mouse-y;
|
||
// Middle button pans. Shift is not consulted here: the
|
||
// modifier state belongs to the key handler, not the pointer
|
||
// event, and a middle-drag is the unambiguous gesture.
|
||
self.panning = e.button == PointerEventButton.middle;
|
||
if (!self.panning && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
}
|
||
if (e.kind == PointerEventKind.up) { self.panning = false; }
|
||
}
|
||
|
||
scroll-event(e) => {
|
||
// Wheel zooms rather than scrolls: the sidebar is an axis, not a
|
||
// list, and a scroll gesture over it means "show me more or less
|
||
// time" rather than "move down".
|
||
root.zoom(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
|
||
changed has-hover => {
|
||
if (!self.has-hover) { root.hovered = -1; }
|
||
}
|
||
}
|
||
}
|
||
|
||
export struct LibraryCell {
|
||
name: string,
|
||
// Non-empty on the first cell of a new month, e.g. "August 2026". The grid
|
||
// is ordered by capture time, so these are the only place the date is
|
||
// legible without consulting the sidebar — a wall of thumbnails otherwise
|
||
// gives no sense of when you are.
|
||
period-heading: string,
|
||
// Empty until a fetch lands. `has-thumb` disambiguates, because Slint
|
||
// cannot test an image against null.
|
||
thumbnail: image,
|
||
has-thumb: bool,
|
||
// A fetch that completed with no usable preview. Distinct from pending.
|
||
unavailable: bool,
|
||
// Part of the current selection. Selection is what a drag carries, so this
|
||
// has to be per-cell state rather than a single "current" index.
|
||
selected: bool,
|
||
// How many collections this image belongs to. An image can be in many at
|
||
// once, and without a cue the grid gives no hint that a photograph has
|
||
// already been filed — the user re-files it, or hunts for where it went.
|
||
collection-count: int,
|
||
// This cell is one of the images currently being dragged. It reads as
|
||
// *lifted out*: desaturated and shrunk in place, so the grid shows where
|
||
// the photographs came from while the cursor shows them in full colour.
|
||
lifted: bool,
|
||
// Stars, 0..5. Zero is *unrated* — a state of its own, not a low score,
|
||
// and what "filter to unjudged" selects (FR-CULL-4).
|
||
rating: int,
|
||
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
|
||
// four-star frame is a normal thing to do mid-cull.
|
||
flag: int,
|
||
}
|
||
|
||
// A row of five stars, readable at a glance and clickable to set a rating.
|
||
//
|
||
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
|
||
// status by hue alone, and the palette is achromatic anyway — so a set star is
|
||
// a solid star at `active` and an unset one is an outline at `ink-faint`. The
|
||
// two differ in both shape and luminance, which survives greyscale and low
|
||
// vision alike.
|
||
//
|
||
// **Unrated draws nothing until hovered.** A grid of 120 cells each showing
|
||
// five empty stars is a wall of chrome competing with the photographs; a
|
||
// freshly scanned library would look like a spreadsheet. The strip appears on
|
||
// hover, so an unjudged cell is quiet and a judged one is legible from across
|
||
// the room.
|
||
export component StarStrip inherits Rectangle {
|
||
in property <int> rating: 0;
|
||
/// Show the empty stars even at zero — used while the pointer is over the
|
||
/// cell, so there is something to aim at.
|
||
in property <bool> show-empty: false;
|
||
/// Whether clicking sets a rating. False in a read-only context.
|
||
in property <bool> interactive: true;
|
||
/// Whether to offer the trash target at all. Hidden in the trash view,
|
||
/// where these photographs are already there — `plan_trash` would skip
|
||
/// them anyway, so the control would be inert, and an inert control that
|
||
/// looks live is worse than no control.
|
||
in property <bool> can-trash: true;
|
||
|
||
/// A star was clicked: the rating it stands for, 1..5.
|
||
callback rate(int);
|
||
/// The trash target was clicked. Named separately from [`rate`] rather
|
||
/// than being rating `-1`: this moves a file on the server, and a callback
|
||
/// that could mean either "set a rating" or "delete a photograph"
|
||
/// depending on the sign is one typo away from the wrong one.
|
||
callback trash();
|
||
|
||
// One star's drawn box. The *ink* stays small — five 44px stars would be
|
||
// 220px wide and swamp a 180px cell — while the hit area below grows to
|
||
// `Theme.touch-target`, which is the same split `Button` and `FormatCheck`
|
||
// make for the same FR-UI-3 reason.
|
||
property <length> star: 22px;
|
||
|
||
// Separation between the trash target and ★1.
|
||
//
|
||
// **This gap is load-bearing.** The star targets are 44px over a 22px
|
||
// cell, so they deliberately overlap and a near-miss lands one star out —
|
||
// harmless, same control, corrected by clicking again. That reasoning does
|
||
// not survive a neighbour that *moves a file*, so trash is held off the
|
||
// scale by a gap wider than the overhang it would otherwise share with
|
||
// ★1. A slip between them hits nothing at all, which is the correct
|
||
// outcome for an ambiguous press next to a destructive target.
|
||
property <length> trash-gap: 14px;
|
||
|
||
height: root.star;
|
||
// Sized to its content so the cell's layout does not reserve space for a
|
||
// strip that may be invisible.
|
||
width: 5 * root.star + (root.can-trash ? root.star + root.trash-gap : 0px);
|
||
// A ground behind the stars: they sit over a photograph that may be white
|
||
// at that point, and an outline star on a bright sky is invisible. Also
|
||
// makes the strip read as one control rather than five loose marks.
|
||
background: root.visible ? Theme.surface.with-alpha(0.75) : transparent;
|
||
border-radius: Theme.radius;
|
||
visible: root.rating > 0 || root.show-empty;
|
||
|
||
HorizontalLayout {
|
||
// Trash sits at the *left*, before the scale rather than beyond its
|
||
// top: reading left to right it is "remove this" and then a rising
|
||
// scale, which keeps ★5 at the end where a rating scale is expected
|
||
// to peak. Putting it past ★5 would make the strip read as a
|
||
// six-point scale whose last stop deletes.
|
||
Rectangle {
|
||
width: root.can-trash ? root.star : 0px;
|
||
height: root.star;
|
||
visible: root.can-trash;
|
||
|
||
Icon {
|
||
name: "trash";
|
||
// Reject and trash are the two destructive ends of this UI and
|
||
// share the palette's one hue, so the gesture reads the same
|
||
// in both places (NFR-A11Y-3: the shape carries it, not the
|
||
// colour).
|
||
ink: Theme.warn-ink;
|
||
size: 13px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
// Deliberately *not* grown to `touch-target`. Every other
|
||
// target here overhangs to meet FR-UI-3, but that requirement
|
||
// is about reaching a control with a thumb — it is not a
|
||
// reason to make a destructive one easier to hit by accident
|
||
// than the thing beside it. 22px plus the gap is a real target
|
||
// without reaching into ★1's band.
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
clicked => { root.trash(); }
|
||
}
|
||
}
|
||
|
||
// The gap itself, inert — no TouchArea, so a press here does nothing
|
||
// rather than resolving to whichever neighbour is closer.
|
||
Rectangle { width: root.can-trash ? root.trash-gap : 0px; }
|
||
|
||
for n[i] in [1, 2, 3, 4, 5]: Rectangle {
|
||
width: root.star;
|
||
height: root.star;
|
||
|
||
Icon {
|
||
// Solid versus outline: the shape says it, not the colour.
|
||
name: root.rating >= n ? "star" : "star-outline";
|
||
ink: root.rating >= n ? Theme.active : Theme.ink-dim;
|
||
// Large enough to hit the difference between filled and empty
|
||
// at arm's length on a tablet; the two differ in fill, which
|
||
// needs more pixels to read than a difference in shape would.
|
||
size: 14px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
// Grown past the drawn star to meet FR-UI-3's 44pt minimum, and
|
||
// centred on it. The overhang overlaps its neighbours, so the
|
||
// *later* star wins the shared band — which is why this is
|
||
// acceptable here: adjacent targets belong to the same control and
|
||
// a near-miss sets a rating one star out, not something unrelated.
|
||
//
|
||
// Vertical overhang spills outside the strip onto the thumbnail,
|
||
// which carries no hit area of its own — the cell's TouchArea is
|
||
// below this in z-order.
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
width: max(parent.width, Theme.touch-target);
|
||
height: max(parent.height, Theme.touch-target);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
// Clicking the star already set clears the rating — the
|
||
// gesture every photo tool uses for "undo that", and without
|
||
// it the only way back to unrated is the keyboard.
|
||
clicked => { root.rate(root.rating == n ? 0 : n); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The pick/reject mark.
|
||
//
|
||
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
||
// a tick and a cross are distinguishable without hue, and a reject reads as a
|
||
// reject in greyscale. A reject also dims its whole cell, which is the cue
|
||
// that carries at grid scale — the mark is confirmation, not the primary
|
||
// signal.
|
||
export component FlagMark inherits Rectangle {
|
||
in property <int> flag: 0;
|
||
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
visible: root.flag > 0;
|
||
background: Theme.surface;
|
||
opacity: 0.92;
|
||
|
||
Icon {
|
||
name: root.flag == 2 ? "cross" : "check";
|
||
// Reject earns the one hue in the palette: it is the destructive end
|
||
// of the axis and the thing a user must not mistake for a pick.
|
||
ink: root.flag == 2 ? Theme.warn-ink : Theme.active;
|
||
size: 9px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
}
|
||
|
||
// The header's action buttons, in one place so they can be drawn in two.
|
||
//
|
||
// A tablet in portrait is 768 logical pixels wide, and the header wants a
|
||
// title, three status readouts and six buttons. Laid out in one row they all
|
||
// shrink to their minimum and elide — "Change library" arrives as "Change
|
||
// li…" — and the row still overflows its own width. Slint's HorizontalLayout
|
||
// has no wrap and no overflow, so the row has to be told what to drop.
|
||
//
|
||
// Extracted rather than duplicated because the alternative is six buttons
|
||
// written twice with their visibility rules and callbacks copied, and the
|
||
// copy that gets forgotten is the one behind the disclosure nobody opens
|
||
// while testing.
|
||
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;
|
||
/// TRACES: FR-DEV-6
|
||
/// Whether settings have been copied, and what pasting them would apply.
|
||
in property <bool> settings-armed: false;
|
||
in property <string> settings-summary;
|
||
/// TRACES: FR-EXP-7
|
||
/// Whether a batch is running, and where its files are going.
|
||
in property <bool> exporting: false;
|
||
in property <bool> export-to-server: false;
|
||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||
/// is sized to its content.
|
||
in property <bool> centred: true;
|
||
in property <length> row-height: 44px;
|
||
|
||
callback export-selection();
|
||
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();
|
||
callback rescan();
|
||
callback open-settings();
|
||
|
||
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
|
||
// permanently visible button that is usually disabled teaches the user to
|
||
// stop reading this row.
|
||
//
|
||
// The count is in the label rather than in a confirmation: this writes to
|
||
// every selected image, and "Paste to 40" said before the click is worth
|
||
// more than a dialogue asking the same question after it.
|
||
if root.selected-count > 0 && root.settings-armed: Button {
|
||
text: "Paste to " + root.selected-count;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.paste-settings-to-selection(); }
|
||
}
|
||
|
||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||
// Export the selection, and stop the batch that is running.
|
||
//
|
||
// One button doing both, because they are the same thought a moment apart
|
||
// and a separate cancel would have to appear from somewhere — shifting the
|
||
// row under the pointer at the exact moment the user is reaching for it.
|
||
//
|
||
// It stays while a batch runs whatever the selection has since become: the
|
||
// run is what the button now refers to, and a batch of three hundred that
|
||
// could only be stopped by not changing the selection would be a trap.
|
||
//
|
||
// The count is in the label rather than behind a confirmation, exactly as
|
||
// the paste above puts it there: "Export 40" read before the click is worth
|
||
// more than a dialogue asking the same question after it.
|
||
if root.selected-count > 0 || root.exporting: Button {
|
||
text: root.exporting
|
||
? "Cancel export"
|
||
: (root.export-to-server
|
||
? "Export " + root.selected-count + " to Nextcloud"
|
||
: "Export " + root.selected-count);
|
||
active: root.exporting;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => {
|
||
if (root.exporting) {
|
||
root.cancel-export();
|
||
} else {
|
||
root.export-selection();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Removing from a collection is only meaningful while the grid is scoped
|
||
// to one. Offering it unscoped would invite the reading "remove from the
|
||
// library", which nothing here does.
|
||
if root.scope-label != "" && root.selected-count > 0: Button {
|
||
text: "Remove from collection";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.remove-from-collection(); }
|
||
}
|
||
|
||
if !root.scanning: Button {
|
||
text: "Change library";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.change-library(); }
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// Keep this collection offline. Only offered when the grid is scoped to
|
||
// one: "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.
|
||
if root.scope-label != "": Button {
|
||
// 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 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;
|
||
clicked => { root.toggle-pin-scope(); }
|
||
}
|
||
|
||
Button {
|
||
// Shares the finished index so a second device inherits it rather
|
||
// than repeating hours of range fetches.
|
||
text: root.syncing ? "Syncing…" : "Sync";
|
||
enabled: !root.syncing && !root.scanning;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.sync-now(); }
|
||
}
|
||
|
||
if !root.scanning: Button {
|
||
text: "Rescan";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.rescan(); }
|
||
}
|
||
|
||
// Last in the row, and unconditional. The buttons before it come and go
|
||
// with what the grid is showing; settings is always reachable, and a
|
||
// control that moved as its neighbours appeared would be hunted for each
|
||
// time.
|
||
Button {
|
||
text: "Settings";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-settings(); }
|
||
}
|
||
}
|
||
|
||
export component LibraryGrid inherits Rectangle {
|
||
/// TRACES: FR-UI-1
|
||
/// The layout class, from the window width rather than the device.
|
||
///
|
||
/// Compact moves the header's actions behind a disclosure; see
|
||
/// [`HeaderActions`]. Defaults to expanded so a caller that forgets to
|
||
/// pass it gets the desktop layout rather than a hidden toolbar.
|
||
in property <bool> expanded: true;
|
||
/// Whether the compact action row is showing. Local to the grid: it is a
|
||
/// disclosure, not a preference, and it should close itself the moment
|
||
/// the window is wide enough not to need it.
|
||
property <bool> actions-open: false;
|
||
changed expanded => {
|
||
if (root.expanded) { root.actions-open = false; }
|
||
}
|
||
|
||
in property <[LibraryCell]> cells;
|
||
in property <int> total: 0;
|
||
in property <bool> scanning: false;
|
||
in property <string> scan-status: "";
|
||
in property <string> scan-error: "";
|
||
/// Which folder is being shown. Visible at all times: two similarly-named
|
||
/// folders are easy to confuse, and a scan of the wrong one looks
|
||
/// identical to a broken scan.
|
||
in property <string> root-label: "";
|
||
|
||
// --- capture-time scrubber ---
|
||
in property <[TimelineBar]> timeline;
|
||
in property <string> timeline-label: "";
|
||
/// Dates spanned by the cells currently shown.
|
||
in property <string> window-label: "";
|
||
in property <int> offset: 0;
|
||
/// Where the view should be, as an image ordinal. A scrub sets this; the
|
||
/// grid follows it.
|
||
///
|
||
/// Bumped by `scroll-token` rather than watched directly: scrubbing twice
|
||
/// to the same date must still move the view, and an unchanged property
|
||
/// fires no `changed` handler.
|
||
in property <int> scroll-to: 0;
|
||
in property <int> scroll-token: 0;
|
||
|
||
/// Which bucket the grid currently sits in, and how far along the visible
|
||
/// span that is. Rust supplies both: it owns the span, so only it can turn
|
||
/// an instant into the fraction that places the marker.
|
||
in property <int> current-bucket: 0;
|
||
in property <float> current-bucket-fraction: -1;
|
||
/// False until the user has moved the timeline themselves, so the marker
|
||
/// rests at the middle rather than implying a choice not yet made.
|
||
in property <bool> timeline-anchored: false;
|
||
|
||
/// A fraction along the visible span, not a bucket index — the timeline
|
||
/// interpolates so a slow drag tracks the finger rather than snapping.
|
||
callback scrub-fraction(float);
|
||
callback timeline-pan(float);
|
||
callback timeline-zoom(int);
|
||
/// Pinch ratio: above 1 spreads (zoom in), below 1 pinches (zoom out).
|
||
callback timeline-pinch(float);
|
||
/// Ctrl+wheel or pinch over the grid: resize the cells. A signed step,
|
||
/// not a size, so Rust owns the bounds.
|
||
callback zoom-cells(int);
|
||
callback columns-changed(int);
|
||
callback sync-now();
|
||
/// The grid scrolled: the first visible image's ordinal in the library.
|
||
/// Rust answers by loading the window around that position.
|
||
callback scrolled(int);
|
||
/// The viewport can now hold a different number of cells — a resize, or a
|
||
/// column-count change. Rust resizes the loaded window to match.
|
||
callback capacity-changed(int);
|
||
|
||
// Thumbnail progress is no longer reported here: the batch belongs to a
|
||
// window of the grid rather than to anything the user asked for, and the
|
||
// shell's bar now carries it along with every other running job.
|
||
|
||
// Whole-library indexing, which runs for far longer than one window's
|
||
// thumbnails and is reported separately so the two do not fight over the
|
||
// same line.
|
||
in property <int> sweep-done: 0;
|
||
in property <int> sweep-total: 0;
|
||
/// Pushing shards and the catalog to the server.
|
||
in property <bool> syncing: false;
|
||
property <bool> sweeping: root.sweep-total > 0 && root.sweep-done < root.sweep-total;
|
||
|
||
callback cell-clicked(int);
|
||
/// A star was clicked on a cell: row, and the rating 0..5.
|
||
callback cell-rated(int, int);
|
||
/// Whether the grid is currently listing the trash rather than the
|
||
/// library. Suppresses the per-cell trash target, which would be inert
|
||
/// there — `plan_trash` skips an already-trashed image — and offering a
|
||
/// control that does nothing is worse than offering none.
|
||
in property <bool> viewing-trash: false;
|
||
/// The trash target was clicked on a cell. Acts on that one photograph,
|
||
/// like the stars beside it — the pointer names it unambiguously, and a
|
||
/// click that quietly trashed a whole selection would be a trap. `Delete`
|
||
/// is the bulk gesture.
|
||
callback cell-trashed(int);
|
||
/// Move the selection to the trash — the `Delete` key.
|
||
callback trash-selection();
|
||
/// A rating or flag key was pressed while the grid had focus. Applies to
|
||
/// the whole selection, which is what makes rating forty frames one
|
||
/// gesture.
|
||
///
|
||
/// Rating and flag travel on one callback because they are one keystroke
|
||
/// as far as the user is concerned; Rust decodes which axis was meant.
|
||
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
|
||
/// star, so neither axis is disturbed by a press on the other.
|
||
callback judged(int, int);
|
||
/// `F2` — rename the collection the grid is scoped to. The key lives with
|
||
/// the grid because that is what holds focus in library mode, but the
|
||
/// rename itself happens in the sidebar.
|
||
callback rename-scope();
|
||
/// Whether the collections sidebar is currently shown. The toggle for it
|
||
/// lives here rather than in the sidebar itself for the obvious reason: a
|
||
/// control inside a closed panel cannot reopen it.
|
||
in property <bool> collections-visible: true;
|
||
callback toggle-collections();
|
||
|
||
callback rescan();
|
||
/// Back to the launch screen, to change library or account.
|
||
callback change-library();
|
||
/// Open the settings page.
|
||
callback open-settings();
|
||
|
||
// --- selection and drag ---
|
||
//
|
||
// A click selects; ctrl-click adds to the selection; shift-click extends a
|
||
// range. Dragging a selected cell carries the whole selection, which is
|
||
// what makes "put these forty photographs in that collection" one gesture.
|
||
//
|
||
// The drag itself is Slint's own `DragArea`, not a hand-rolled gesture. The
|
||
// first attempt here tracked presses and travel through a `TouchArea` and
|
||
// failed for a reason worth recording: an interactive `Flickable` claims any
|
||
// drag that begins inside it for scrolling, cancelling the child
|
||
// TouchArea's press, so the gesture could never leave the grid. `DragArea`
|
||
// is arbitrated properly against the Flickable, keeps the pointer capture
|
||
// across component boundaries, and draws its own cursor overlay — which is
|
||
// also why there is no badge position to compute here any more.
|
||
/// 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;
|
||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||
/// of them where several are being carried. Composited in Rust, because
|
||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||
in property <image> drag-image;
|
||
/// A drag began on this cell. Lets Rust promote an unselected cell to the
|
||
/// selection before the payload is read.
|
||
callback drag-started(int);
|
||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||
callback drag-finished();
|
||
|
||
// --- rating filter (FR-CAT-6, FR-CULL-4) ---
|
||
//
|
||
// The filter narrows what the grid *queries*, not what it draws: on a
|
||
// remote library, drawing then hiding would still have fetched every
|
||
// thumbnail, which is the cost FR-NC-3 exists to avoid.
|
||
/// Minimum stars to show. 0 shows everything.
|
||
in property <int> filter-min-rating: 0;
|
||
/// Show only images nothing has judged yet — FR-CULL-4's "filter to
|
||
/// unjudged", which is what lets a culling session resume.
|
||
in property <bool> filter-unjudged: false;
|
||
/// 0 no flag filter, 1 picks only, 2 rejects only.
|
||
in property <int> filter-flag: 0;
|
||
/// How many images sit at each star count, index 0 being unrated. Shown
|
||
/// on the filter buttons so the user can see there is something behind a
|
||
/// filter before narrowing to it — a filter that silently empties the
|
||
/// grid reads as broken.
|
||
in property <[int]> rating-counts;
|
||
|
||
callback filter-min-rating-changed(int);
|
||
callback filter-unjudged-toggled(bool);
|
||
callback filter-flag-changed(int);
|
||
|
||
// --- offline (FR-CAT-9) -------------------------------------------------
|
||
//
|
||
// Offline is a banner rather than a modal or an empty state, because most
|
||
// of the library still works: the shards hold the thumbnails, and rating,
|
||
// flagging, sorting and collecting are catalog writes that never touched
|
||
// the network. Only opening an un-cached original actually fails.
|
||
in property <bool> offline: false;
|
||
in property <string> offline-reason: "";
|
||
in property <string> offline-since: "";
|
||
callback retry-connection();
|
||
|
||
// --- pinning (FR-NC-6a) -----------------------------------------------
|
||
//
|
||
// Whether the collection the grid is scoped to is kept offline, and how
|
||
// far the download has got. Progress is shown because pinning a trip is
|
||
// gigabytes of transfer — a button that appeared to do nothing for twenty
|
||
// minutes would read as broken.
|
||
in property <bool> scope-pinned: false;
|
||
in property <int> pin-done: 0;
|
||
in property <int> pin-total: 0;
|
||
callback toggle-pin-scope();
|
||
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
||
in property <bool> local-only: false;
|
||
in property <int> local-count: 0;
|
||
callback toggle-local-only();
|
||
|
||
/// How many images are selected, for the header's count.
|
||
in property <int> selected-count: 0;
|
||
|
||
// TRACES: FR-DEV-6
|
||
// Batch-applying copied develop settings to the selection. The clipboard
|
||
// itself belongs to the window — a copy is taken in the develop view and
|
||
// pasted here — so the grid only reports what it has and asks.
|
||
in property <bool> settings-armed: false;
|
||
in property <string> settings-summary;
|
||
callback paste-settings-to-selection();
|
||
|
||
// TRACES: FR-EXP-7
|
||
// Exporting the selection. The grid owns neither the settings that decide
|
||
// where the files go nor the worker that writes them — it reports what is
|
||
// selected and asks, exactly as it does for a paste.
|
||
in property <bool> exporting: false;
|
||
in property <bool> export-to-server: false;
|
||
callback export-selection();
|
||
callback cancel-export();
|
||
|
||
// --- the keyboard cursor ------------------------------------------------
|
||
//
|
||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
||
// row of the loaded window, which names a different photograph after every
|
||
// scroll. Rust owns it, because clamping it needs the library's length and
|
||
// moving it may have to swap the window underneath.
|
||
//
|
||
// -1 before the user has taken hold of it, so a fresh grid draws no cursor
|
||
// and the first arrow press picks up where the view already is rather than
|
||
// teleporting to image zero.
|
||
in property <int> cursor: -1;
|
||
/// Move the cursor by a number of images; the flag extends the selection
|
||
/// from the anchor instead of replacing it.
|
||
///
|
||
/// A signed step and nothing else. Home and End are this with a step
|
||
/// longer than the library, which Rust clamps — so this file needs to know
|
||
/// neither how many images there are nor where the loaded window starts.
|
||
callback move-cursor(int, bool);
|
||
/// Open the image under the cursor. `Return`, and the reason the arrows
|
||
/// are worth having: a cull is walk, judge, open, back, without the hand
|
||
/// ever leaving the keyboard.
|
||
callback open-cursor();
|
||
/// Which collection scopes the grid, for the header. Empty means all.
|
||
in property <string> scope-label: "";
|
||
/// Take the selection out of the collection currently being shown. Only
|
||
/// offered when the grid is scoped to one — "remove from library" is not a
|
||
/// 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
|
||
// judging one, and more, smaller ones for finding one. Driven from Rust so
|
||
// the value survives a scope change and the thumbnail class can follow it.
|
||
in property <length> cell-size: 180px;
|
||
property <int> columns: max(1, floor((self.width - Theme.gap) / (cell-size + Theme.gap)));
|
||
// Reported out so Rust can place month headings: a heading belongs on a
|
||
// cell that begins a row, and only the grid knows how wide a row is.
|
||
changed columns => { root.columns-changed(root.columns); }
|
||
property <int> row-count: ceil(root.cells.length / max(1, columns));
|
||
|
||
// How many cells the viewport holds, plus a screenful either side so
|
||
// scrolling has loaded rows to move into rather than blank ones.
|
||
//
|
||
// Derived rather than a constant: a fixed window is simultaneously too
|
||
// small on a maximised 4K window — where three screenfuls fit inside it —
|
||
// and wasteful on a narrow one.
|
||
property <int> visible-rows: max(1, ceil(self.height / (cell-size + Theme.gap)));
|
||
property <int> capacity: root.columns * (root.visible-rows * 3);
|
||
changed capacity => { root.capacity-changed(root.capacity); }
|
||
/// Rows the *whole library* occupies, which is what the scrollbar spans.
|
||
property <int> total-rows: ceil(root.total / max(1, columns));
|
||
|
||
background: Theme.ground;
|
||
|
||
VerticalLayout {
|
||
// --- header -------------------------------------------------------
|
||
Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
// The sidebar toggle, leading the header — the place every
|
||
// interface with a collapsible sidebar puts one, and the only
|
||
// place that stays put whichever way the panel is.
|
||
IconButton {
|
||
icon: "menu";
|
||
active: root.collections-visible;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.toggle-collections(); }
|
||
}
|
||
|
||
Value {
|
||
// The collection being shown takes the title when the grid
|
||
// is scoped to one: that is what the user narrowed to, and
|
||
// the folder is the less specific fact by then.
|
||
text: root.scope-label != "" ? root.scope-label
|
||
: (root.root-label != "" ? root.root-label : "Library");
|
||
overflow: elide;
|
||
}
|
||
|
||
Caption { text: root.total > 0 ? root.total + " images" : ""; }
|
||
|
||
// The selection count, and how to act on it. Both appear only
|
||
// when something is selected — an empty selection has nothing
|
||
// to say and the buttons would be permanently greyed chrome.
|
||
// A live selection is state, not a label — it is the thing the
|
||
// buttons beside it act on — so it keeps `active` rather than
|
||
// dropping to ink with the counts around it.
|
||
Value {
|
||
text: root.selected-count > 0
|
||
? root.selected-count + " selected" : "";
|
||
modified: true;
|
||
compact: true;
|
||
font-weight: 600;
|
||
}
|
||
|
||
Label {
|
||
// The scan's own status while it runs; once it is done,
|
||
// thumbnail progress takes the line over — that is the
|
||
// work the user is actually waiting on by then.
|
||
// No per-window preview count: it counted an arbitrary
|
||
// batch, so "48 / 120" described the window's size rather
|
||
// than anything the user cares about. Whole-library
|
||
// indexing is the number worth showing, and the bar below
|
||
// already says that the window itself is still filling.
|
||
text: root.scanning ? root.scan-status
|
||
: (root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.scan-status);
|
||
}
|
||
|
||
// Where in the library the visible window sits. A scrubbable
|
||
// grid is disorienting without it.
|
||
Caption {
|
||
text: root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.window-label;
|
||
horizontal-alignment: right;
|
||
horizontal-stretch: 1;
|
||
overflow: elide;
|
||
}
|
||
|
||
// Expanded: the actions sit in the header as one row. There
|
||
// is room, and a disclosure would be a click in front of a
|
||
// button that was already visible.
|
||
if root.expanded: HeaderActions {
|
||
scope-label: root.scope-label;
|
||
selected-count: root.selected-count;
|
||
scanning: root.scanning;
|
||
syncing: root.syncing;
|
||
scope-pinned: root.scope-pinned;
|
||
settings-armed: root.settings-armed;
|
||
settings-summary: root.settings-summary;
|
||
exporting: root.exporting;
|
||
export-to-server: root.export-to-server;
|
||
export-selection => { root.export-selection(); }
|
||
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(); }
|
||
rescan => { root.rescan(); }
|
||
open-settings => { root.open-settings(); }
|
||
}
|
||
|
||
// Compact: one button in place of six. Labelled rather than a
|
||
// bare glyph, because "⋯" alone in a header of words reads as
|
||
// a truncation of the label beside it — which is exactly what
|
||
// this change exists to stop happening.
|
||
if !root.expanded: Button {
|
||
text: root.actions-open ? "Close" : "More";
|
||
active: root.actions-open;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.actions-open = !root.actions-open; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// The compact action row, disclosed by "More" above.
|
||
//
|
||
// A row beneath the header rather than a popup over the grid: a popup
|
||
// needs a dismiss rule, an anchor and a decision about what happens
|
||
// when the window resizes under it, and all this needs to be is the
|
||
// six buttons somewhere they fit. It closes itself when the window
|
||
// widens — see `actions-open`.
|
||
if !root.expanded && root.actions-open: Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
alignment: start;
|
||
|
||
HeaderActions {
|
||
centred: true;
|
||
scope-label: root.scope-label;
|
||
selected-count: root.selected-count;
|
||
scanning: root.scanning;
|
||
syncing: root.syncing;
|
||
scope-pinned: root.scope-pinned;
|
||
settings-armed: root.settings-armed;
|
||
settings-summary: root.settings-summary;
|
||
exporting: root.exporting;
|
||
export-to-server: root.export-to-server;
|
||
export-selection => { root.export-selection(); }
|
||
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(); }
|
||
rescan => { root.rescan(); }
|
||
open-settings => { root.open-settings(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- rating filter ------------------------------------------------
|
||
//
|
||
// Hidden while there is nothing to filter: an empty library offering
|
||
// six rating buttons is chrome describing data that does not exist.
|
||
if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged
|
||
|| root.filter-flag > 0: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: 4px;
|
||
alignment: start;
|
||
|
||
Caption {
|
||
text: "Show";
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
// Minimum-stars buttons. "All" first, then 1..5 — the same
|
||
// left-to-right increasing order as the star strip itself, so
|
||
// the two read as the same scale.
|
||
FilterChip {
|
||
label: "All";
|
||
active: root.filter-min-rating == 0 && !root.filter-unjudged
|
||
&& root.filter-flag == 0;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-min-rating-changed(0);
|
||
root.filter-unjudged-toggled(false);
|
||
root.filter-flag-changed(0);
|
||
}
|
||
}
|
||
|
||
// Unrated, which is where a freshly scanned library lives in
|
||
// its entirety — and what a resumed cull filters to.
|
||
FilterChip {
|
||
label: "Unrated";
|
||
count: root.rating-counts.length > 0 ? root.rating-counts[0] : -1;
|
||
active: root.filter-unjudged;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-unjudged-toggled(!root.filter-unjudged);
|
||
}
|
||
}
|
||
|
||
for n[i] in [1, 2, 3, 4, 5]: FilterChip {
|
||
icon: "star";
|
||
label: n + "+";
|
||
count: root.rating-counts.length > n ? root.rating-counts[n] : -1;
|
||
active: root.filter-min-rating == n;
|
||
y: (parent.height - self.height) / 2;
|
||
// Pressing the active one clears it, so the filter is its
|
||
// own undo and "All" is not the only way back.
|
||
clicked => {
|
||
root.filter-min-rating-changed(
|
||
root.filter-min-rating == n ? 0 : n);
|
||
}
|
||
}
|
||
|
||
Rectangle { width: Theme.gap; }
|
||
|
||
FilterChip {
|
||
icon: "check";
|
||
label: "Picks";
|
||
active: root.filter-flag == 1;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-flag-changed(root.filter-flag == 1 ? 0 : 1);
|
||
}
|
||
}
|
||
|
||
FilterChip {
|
||
icon: "cross";
|
||
label: "Rejects";
|
||
active: root.filter-flag == 2;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-flag-changed(root.filter-flag == 2 ? 0 : 2);
|
||
}
|
||
}
|
||
|
||
Rectangle { width: Theme.gap; }
|
||
|
||
// Locally-stored originals. Always offered, not only when
|
||
// offline: "what can I actually work on right now" is a fair
|
||
// question on a slow connection too, and a control that
|
||
// appears only in the failure case is one the user has to
|
||
// discover at the worst moment.
|
||
FilterChip {
|
||
label: "On this device";
|
||
count: root.local-count;
|
||
active: root.local-only;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.toggle-local-only(); }
|
||
}
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
|
||
// What the filter is currently hiding. Without this a narrowed
|
||
// grid and an empty library look identical, which is the
|
||
// single most confusing state a filter can leave behind.
|
||
Caption {
|
||
text: (root.filter-min-rating > 0 || root.filter-unjudged
|
||
|| root.filter-flag > 0 || root.local-only)
|
||
? "filtered" : "";
|
||
emphasised: true;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- progress -----------------------------------------------------
|
||
//
|
||
// The bar that used to sit here is now the shell's, drawn across the
|
||
// top of every view from the activity register (see `activity.rs`).
|
||
// Two reasons it moved. It only ever knew about the three things the
|
||
// grid happens to report — a download running while the user was in
|
||
// develop drew nothing anywhere — and a second bar here would now say
|
||
// the same thing twice, one line apart.
|
||
//
|
||
// The grid keeps its *words*: "indexing 4000 / 17000" in the header
|
||
// above says which work is running, which a bar cannot.
|
||
|
||
// --- pinning ------------------------------------------------------
|
||
//
|
||
// Its own line, with the count spelled out: the shell's bar says that
|
||
// something is transferring, and this says how much of what.
|
||
if root.pin-total > 0: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Caption {
|
||
text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
ProgressBar {
|
||
fraction: root.pin-done / max(1, root.pin-total);
|
||
y: (parent.height - self.height) / 2;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- offline ------------------------------------------------------
|
||
//
|
||
// Above the scan error, and it suppresses it: when the server is
|
||
// unreachable the scan failure is a *consequence*, and showing both
|
||
// reports one problem twice while implying two.
|
||
if root.offline: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Caption {
|
||
text: "Offline — showing what is stored on this device"
|
||
+ (root.offline-since != "" ? " (" + root.offline-since + ")" : "");
|
||
warn: true;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The transport's own words. Usually specific enough to act on
|
||
// — "connection refused" and "dns error" send the user to
|
||
// different places — where a bare "offline" leaves them
|
||
// guessing whether it is their wifi or the server.
|
||
Caption {
|
||
text: root.offline-reason;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
Button {
|
||
text: "Retry";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.retry-connection(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- error --------------------------------------------------------
|
||
if root.scan-error != "" && !root.offline: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
Caption {
|
||
text: root.scan-error;
|
||
warn: true;
|
||
horizontal-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// --- body: sidebar beside the grid --------------------------------
|
||
//
|
||
// The capture-time axis is furniture, not a strip under the images: a
|
||
// scroll position is relative, a date is absolute, and this is the
|
||
// primary way of moving through the library.
|
||
HorizontalLayout {
|
||
vertical-stretch: 1;
|
||
|
||
// Always present, never conditional on having bars.
|
||
//
|
||
// Creating it on `timeline.length > 0` made the sidebar's 96px
|
||
// appear the moment the first dates were recorded, which narrowed
|
||
// the grid — changing `columns` and `capacity`, both of which call
|
||
// back into Rust to reload the window. The first sweep flush
|
||
// therefore landed a reload storm on top of the initial thumbnail
|
||
// batch. Reserving the column costs 96px on an undated library and
|
||
// keeps the grid's width stable while dates arrive.
|
||
//
|
||
// An empty `bars` already renders as bare furniture: the `for`
|
||
// loops produce nothing and the gestures index an empty array only
|
||
// under a pointer that has no bar to land on.
|
||
Timeline {
|
||
bars: root.timeline;
|
||
range-label: root.timeline-label;
|
||
current-start: root.current-bucket;
|
||
current-fraction: root.current-bucket-fraction;
|
||
anchored: root.timeline-anchored;
|
||
|
||
scrub-to(f) => { root.scrub-fraction(f); }
|
||
pinch(r) => { root.timeline-pinch(r); }
|
||
pan(d) => { root.timeline-pan(d); }
|
||
zoom(d) => { root.timeline-zoom(d); }
|
||
}
|
||
|
||
VerticalLayout {
|
||
horizontal-stretch: 1;
|
||
|
||
// --- empty state --------------------------------------------------
|
||
//
|
||
// "Still scanning" and "scanned, found nothing" are different answers.
|
||
// Conflating them is how a working scan looks broken.
|
||
if root.total == 0: EmptyState {
|
||
headline: root.scanning ? "Scanning…" : "No images found";
|
||
detail: root.scanning ? root.scan-status
|
||
: "Check the library folder and which formats are ticked.";
|
||
}
|
||
|
||
// --- keyboard judgement (FR-CULL-4) -------------------------------
|
||
//
|
||
// `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag.
|
||
// These are the keys every culling tool uses, and muscle memory
|
||
// built elsewhere is worth more here than any improvement.
|
||
//
|
||
// Zero-height rather than wrapping the grid: a FocusScope in this
|
||
// layout would claim a slot and push the grid up, and one *around*
|
||
// the Flickable competes with it for the arrow keys. This holds
|
||
// focus and forwards nothing else.
|
||
//
|
||
// Applies to the **selection**, not to a cell under the pointer —
|
||
// that is what makes rating forty frames a single keystroke, and it
|
||
// matches what the header's count says is selected.
|
||
judge-keys := FocusScope {
|
||
height: 0px;
|
||
// The grid is the primary surface of this screen, so it takes
|
||
// focus on show rather than waiting for a click. Without this
|
||
// the first keystroke of a culling session is swallowed.
|
||
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; }
|
||
if (event.text == "3") { root.judged(3, -1); return accept; }
|
||
if (event.text == "4") { root.judged(4, -1); return accept; }
|
||
if (event.text == "5") { root.judged(5, -1); return accept; }
|
||
// Case-insensitive: caps lock during a long cull must not
|
||
// silently stop the keys working.
|
||
if (event.text == "p" || event.text == "P") {
|
||
root.judged(-1, 1);
|
||
return accept;
|
||
}
|
||
if (event.text == "x" || event.text == "X") {
|
||
root.judged(-1, 2);
|
||
return accept;
|
||
}
|
||
if (event.text == "u" || event.text == "U") {
|
||
root.judged(-1, 0);
|
||
return accept;
|
||
}
|
||
// Delete moves the selection to the trash folder on the
|
||
// server. Unlike every other key here it is not metadata —
|
||
// it relocates files — but it is also the key every file
|
||
// manager binds to exactly this, and the operation is
|
||
// reversible from the trash view.
|
||
if (event.text == Key.Delete || event.text == Key.Backspace) {
|
||
root.trash-selection();
|
||
return accept;
|
||
}
|
||
// `F2` renames the collection the grid is scoped to — the
|
||
// rename key everywhere else, and the reason it is bound
|
||
// here is that this scope is what holds focus in library
|
||
// mode. Rust ignores it when nothing is scoped.
|
||
if (event.text == Key.F2) {
|
||
root.rename-scope();
|
||
return accept;
|
||
}
|
||
|
||
// --- walking the grid ---------------------------------
|
||
//
|
||
// The keys that make a cull possible without the mouse:
|
||
// arrows move the cursor, shift extends the selection from
|
||
// the anchor, `Return` opens what the cursor is on. The
|
||
// judgement keys above act on the selection, so walking
|
||
// with the arrows and rating as you go is one hand's work.
|
||
//
|
||
// Every one of these is `accept`ed. The `Flickable` scrolls
|
||
// on arrow keys of its own accord, and letting it would
|
||
// move the view out from under a cursor that had not
|
||
// moved — the grid is scrolled *to* the cursor instead,
|
||
// and only when the cursor leaves the viewport.
|
||
//
|
||
// The vertical steps are expressed in columns and rows,
|
||
// which only the grid knows: how far "down" is depends on
|
||
// how wide the window happens to be.
|
||
if (event.text == Key.LeftArrow) {
|
||
root.move-cursor(-1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.RightArrow) {
|
||
root.move-cursor(1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.UpArrow) {
|
||
root.move-cursor(-root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.DownArrow) {
|
||
root.move-cursor(root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageUp) {
|
||
root.move-cursor(-root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageDown) {
|
||
root.move-cursor(root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
// A step longer than the library, clamped at the far end.
|
||
// `total` is what the grid was told the library holds, so
|
||
// this stays honest as it grows.
|
||
if (event.text == Key.Home) {
|
||
root.move-cursor(-root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.End) {
|
||
root.move-cursor(root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.Return) {
|
||
root.open-cursor();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// --- the grid -----------------------------------------------------
|
||
//
|
||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
||
// properly, so dragging a cell drags the cell and dragging the
|
||
// background still flicks the grid. (This is the part a hand-rolled
|
||
// TouchArea gesture could not do — see the drag comments above.)
|
||
if root.total > 0: grid-scroll := Flickable {
|
||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||
// falls through to the Flickable's own scrolling. Two jobs on
|
||
// one gesture, distinguished by the modifier — the convention
|
||
// every image browser uses.
|
||
//
|
||
// Declared *first* so it sits beneath the cells in z-order:
|
||
// their own touch areas still take clicks and drags, and only
|
||
// a wheel event nothing else claimed reaches this.
|
||
zoom-catcher := TouchArea {
|
||
width: 100%;
|
||
height: parent.viewport-height;
|
||
scroll-event(e) => {
|
||
if (e.modifiers.control) {
|
||
root.zoom-cells(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// Two-finger pinch, for tablet: the same gesture the timeline
|
||
// uses, applied to cell size rather than to time.
|
||
grid-pinch := ScaleRotateGestureHandler {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => { self.last-scale = 1.0; }
|
||
updated => {
|
||
// A quarter-step either way is enough to act on: cell
|
||
// size is continuous, unlike the timeline's discrete
|
||
// zoom levels.
|
||
if (self.scale / max(0.01, self.last-scale) > 1.15) {
|
||
root.zoom-cells(1);
|
||
self.last-scale = self.scale;
|
||
} else if (self.scale / max(0.01, self.last-scale) < 0.87) {
|
||
root.zoom-cells(-1);
|
||
self.last-scale = self.scale;
|
||
}
|
||
}
|
||
ended => { self.last-scale = 1.0; }
|
||
cancelled => { self.last-scale = 1.0; }
|
||
}
|
||
|
||
// Follow a requested position. Without this a scrub moves the
|
||
// *loaded window* while the viewport stays where it was, so
|
||
// the cells are drawn thousands of rows away and the grid
|
||
// looks empty until the user scrolls to find them.
|
||
function seek() {
|
||
self.viewport-y = -min(
|
||
max(0px, self.viewport-height - self.height),
|
||
floor(root.scroll-to / max(1, root.columns))
|
||
* (root.cell-size + Theme.gap));
|
||
}
|
||
|
||
property <int> token: root.scroll-token;
|
||
changed token => { self.seek(); }
|
||
|
||
// Keep the keyboard cursor in view, moving as little as will
|
||
// do it.
|
||
//
|
||
// Deliberately *not* `seek()`: that puts the requested row at
|
||
// the top, which is right for a scrub — the user asked to go
|
||
// to a date and expects to arrive there — and wrong for an
|
||
// arrow key, where the grid jumping a row upward on every
|
||
// press makes the row impossible to read. So a cursor already
|
||
// on screen moves nothing at all, and one that has just left
|
||
// brings in exactly its own row.
|
||
property <length> pitch: root.cell-size + Theme.gap;
|
||
property <int> cursor-row: floor(root.cursor / max(1, root.columns));
|
||
changed cursor-row => { self.reveal(); }
|
||
|
||
function reveal() {
|
||
if (root.cursor < 0) {
|
||
return;
|
||
}
|
||
let top = Theme.gap + self.cursor-row * self.pitch;
|
||
let shown = -self.viewport-y;
|
||
let bottom = max(0px, self.viewport-height - self.height);
|
||
if (top < shown) {
|
||
self.viewport-y = -min(bottom, top);
|
||
} else if (top + self.pitch > shown + self.height) {
|
||
self.viewport-y = -min(bottom, top + self.pitch - self.height);
|
||
}
|
||
}
|
||
|
||
// Also on creation, which is what returning from the develop
|
||
// view needs. `show-library` gates an `if`, so the grid is built
|
||
// anew and `token` is *initialised* to the already-bumped value
|
||
// rather than changing to it — no `changed` handler fires, and
|
||
// without this the restored position would be dropped and the
|
||
// view would sit at the top.
|
||
init => { self.seek(); }
|
||
|
||
// Sized to the **whole library**, not the loaded window. The
|
||
// scrollbar has to represent 23,971 images or there is no way to
|
||
// reach image 20,000 — dragging it must be a real address, and the
|
||
// window is swapped underneath to match.
|
||
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap;
|
||
|
||
// Report the first fully-scrolled-past row so Rust can move the
|
||
// window. Derived rather than eventful: Slint has no scroll
|
||
// callback, and a `changed` handler on a derived integer fires only
|
||
// when the row actually changes rather than on every pixel.
|
||
property <int> first-visible-row: max(0,
|
||
floor((-self.viewport-y - Theme.gap) / (root.cell-size + Theme.gap)));
|
||
changed first-visible-row => {
|
||
root.scrolled(self.first-visible-row * root.columns);
|
||
}
|
||
|
||
// Month headings, drawn over the grid at the row where each
|
||
// period begins. A separate pass rather than part of the cell,
|
||
// because the heading spans the full width and a cell does not.
|
||
for cell[i] in root.cells: Text {
|
||
x: Theme.gap;
|
||
// Sits in the gap above its row, so it labels the row
|
||
// rather than displacing it.
|
||
y: Theme.gap
|
||
+ floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap)
|
||
- 15px;
|
||
width: parent.width - 2 * Theme.gap;
|
||
text: cell.period-heading;
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-sm;
|
||
font-weight: 700;
|
||
visible: cell.period-heading != "";
|
||
}
|
||
|
||
for cell[i] in root.cells: DragArea {
|
||
// Cells are positioned at their **absolute** place in the
|
||
// library, not their index in the loaded window: the window
|
||
// starts at `offset`, so a cell drawn at window-index 0 belongs
|
||
// wherever `offset` sits in the full grid.
|
||
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap);
|
||
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap);
|
||
width: root.cell-size;
|
||
height: root.cell-size;
|
||
|
||
// Copy, not move: dropping into a collection files the
|
||
// photograph there without taking it out of anywhere else. That
|
||
// is what a join table means, and it is why the modifier-free
|
||
// gesture must not be `move`.
|
||
allow-copy: true;
|
||
data: root.drag-payload();
|
||
// What travels under the cursor is the photograph itself — and
|
||
// where several are being dragged, a stack of them. Composited
|
||
// in Rust (`drag_image`), because Slint takes a single bitmap
|
||
// here and cannot render a pile of thumbnails into one.
|
||
//
|
||
// Read on `dragging` rather than bound continuously: the
|
||
// composite costs a copy per thumbnail, and the grid must not
|
||
// pay it per cell per frame.
|
||
drag-image: root.drag-image;
|
||
|
||
changed dragging => {
|
||
if (self.dragging) {
|
||
root.drag-started(i);
|
||
}
|
||
}
|
||
drag-finished(action) => { root.drag-finished(); }
|
||
|
||
Rectangle {
|
||
// Lifted cells shrink toward their own centre, as though pulled
|
||
// off the page. Inset rather than scaled: Slint has no transform
|
||
// on a plain Rectangle, and insetting keeps the cell's slot in
|
||
// the grid so nothing reflows mid-drag.
|
||
x: cell.lifted ? 10px : 0px;
|
||
y: cell.lifted ? 10px : 0px;
|
||
width: parent.width - 2 * self.x;
|
||
height: parent.height - 2 * self.y;
|
||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||
|
||
background: cell.selected ? Theme.selected : Theme.surface;
|
||
border-radius: Theme.radius;
|
||
// Selection outranks hover: a selected cell must stay legible
|
||
// once the pointer has moved on to the collection it is being
|
||
// dragged toward.
|
||
border-width: cell.selected ? 2px : (cell-touch.has-hover ? 1px : 0px);
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
VerticalLayout {
|
||
padding: 6px;
|
||
spacing: 4px;
|
||
|
||
Rectangle {
|
||
vertical-stretch: 1;
|
||
background: Theme.ground;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
// Faded while lifted, so the grid reads as the place
|
||
// the photograph came *from* and the cursor as where
|
||
// it is now. `colorize` would flatten it to one
|
||
// tint, which loses the picture; dropping opacity
|
||
// toward the ground keeps it recognisable as a ghost
|
||
// of itself.
|
||
// A rejected frame is held back rather than
|
||
// hidden: the cull is reversible, and a photo
|
||
// that vanished on one keypress would make the
|
||
// gesture frightening to use. Dimming is the
|
||
// cue that reads at grid scale — the cross on
|
||
// the mark confirms it up close.
|
||
opacity: cell.lifted ? 0.25
|
||
: (cell.flag == 2 ? 0.4 : 1.0);
|
||
animate opacity { duration: 120ms; }
|
||
}
|
||
|
||
if !cell.has-thumb: Caption {
|
||
text: cell.unavailable ? "no preview" : "…";
|
||
horizontal-alignment: center;
|
||
}
|
||
|
||
// "Already filed, in this many collections." Without it
|
||
// there is no way to tell a filed photograph from an
|
||
// unfiled one, and the user re-files what is already
|
||
// in place.
|
||
if cell.collection-count > 0: Rectangle {
|
||
x: parent.width - self.width - 4px;
|
||
y: 4px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
background: Theme.selected;
|
||
opacity: 0.9;
|
||
|
||
Text {
|
||
text: cell.collection-count;
|
||
color: Theme.ink;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// Pick or reject, top left — the opposite corner
|
||
// from the collection badge so the two never
|
||
// collide on a cell that carries both.
|
||
FlagMark {
|
||
x: 4px;
|
||
y: 4px;
|
||
flag: cell.flag;
|
||
}
|
||
|
||
}
|
||
|
||
Label {
|
||
text: cell.name;
|
||
emphasised: cell.selected;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// Selection only. The drag is the enclosing `DragArea`'s
|
||
// business, and Slint keeps a click distinct from a drag for
|
||
// us — which is exactly the arbitration the hand-rolled version
|
||
// had to fake with a travel threshold.
|
||
cell-touch := TouchArea {
|
||
mouse-cursor: pointer;
|
||
|
||
// 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 || 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
|
||
// a selection gesture and must not navigate away from the
|
||
// grid the user is building a selection in. The modifier
|
||
// state is not carried on `clicked`, so the press above
|
||
// 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 ---
|
||
//
|
||
// **Declared after `cell-touch` on purpose.** Slint hit-tests
|
||
// later siblings first, so a strip nested inside the layout
|
||
// above was underneath the cell-wide TouchArea: the click set
|
||
// a rating *and* fell through to `cell-clicked`, which threw
|
||
// the user into develop on every star press. Z-order is the
|
||
// whole fix — there is no "handled" flag to set, and adding a
|
||
// travel threshold or a timer would be faking arbitration
|
||
// Slint already does correctly once the order is right.
|
||
//
|
||
// It sits over the foot of the thumbnail rather than below it:
|
||
// the caption row is spoken for by the filename, and a third
|
||
// row would cost thumbnail height on every cell to show
|
||
// something that is usually empty.
|
||
StarStrip {
|
||
x: (parent.width - self.width) / 2;
|
||
// Clear of the caption, which is the cell's last row.
|
||
y: parent.height - self.height - 26px;
|
||
rating: cell.rating;
|
||
// Empty stars appear once the pointer is over the cell,
|
||
// so there is something to aim at without filling the
|
||
// grid with chrome. On touch there is no hover, so the
|
||
// strip is always present where a rating exists — and a
|
||
// long-press is not needed to discover it.
|
||
show-empty: cell-touch.has-hover;
|
||
can-trash: !root.viewing-trash;
|
||
rate(n) => { root.cell-rated(i, n); }
|
||
trash() => { root.cell-trashed(i); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// --- 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; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|