Files
DarkRoom/ui/dr-ui/ui/library.slint
T
dtourolleandClaude Opus 5 d913e50948 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>
2026-08-17 12:27:29 +02:00

2037 lines
96 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; }
}
}
}
}
}