Files
DarkRoom/ui/dr-ui/ui/library.slint
T
dtourolleandClaude Opus 5 dce1e66746 Show which cell a shift-click is measuring from
The gesture had a hidden operand. A range runs from the anchor — the last cell
plainly clicked — to the cell shift-clicked, and nothing on screen said which
one the anchor was. A user who could not tell where the range was being measured
from had no way to predict what it would take and no clue why a wrong one came
out wrong; often the anchor is not on screen at all, which is itself the answer
to "why did that select so much".

The anchor is marked with an inner ring, drawn inside the selection ring rather
than in a colour of its own: it has to stay legible against a thumbnail of any
brightness, and a hue would read as a second kind of selection. It is an
ordinal, so it marks a row only while the photograph it names is in the loaded
window — off screen it marks nothing, which is the honest answer, and `anchor`
rides in the cell model beside `selected` so both are pushed by the one pass
that already keeps the grid in step with the selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:50:46 +02:00

3194 lines
152 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, Field } 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";
// TRACES: FR-CAT-5
// One keyword in the keywording sheet, already answered against the selection.
//
// The three-way `coverage` is the whole reason this is a struct rather than a
// list of strings. Applying a word to forty photographs where thirty already
// carry it must not look like applying it to forty that carry none, and
// removing one that only some of them carry must not silently claim to have
// taken it off all forty. Rust computes it, because only Rust knows how big the
// selection is and how many of it each word covers.
export struct KeywordRow {
// Row id in `keyword_terms`, or 0 for a word an image carries that the
// vocabulary has no identity for yet. The sheet acts on `name`, never on
// this, so a 0 costs nothing — it is here so a future rename gesture has
// something to name.
id: int,
name: string,
// 0 none of the selection, 1 some of it, 2 all of it.
coverage: int,
// How many of the selected photographs carry it, for the "3 of 12" that
// makes `coverage: 1` a number rather than a shrug.
selected-count: int,
// How many photographs in the whole library carry it. Lets a word in
// regular use be told from one typed once by mistake.
image-count: int,
}
// 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
// - **drag either end of the range band** — narrow the grid to a period
//
// # 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);
/// TRACES: FR-CAT-6
/// The chosen date range, as fractions of the drawn span — the same
/// coordinates `scrub-to` reports and `current-fraction` is drawn in, so
/// the band, the marker and a click cannot disagree about where a date
/// sits. Negative in both when no range is set.
in property <float> range-from: -1;
in property <float> range-to: -1;
/// An end of the range was dragged, and where both ends now are.
///
/// Sent on release rather than continuously: each one of these re-runs the
/// grid's query and reloads its window, and doing that per frame of a drag
/// is how a filter turns into a stutter. The band tracks the finger
/// meanwhile — that is what `live-from` and `live-to` are for.
callback range-changed(float, 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);
property <bool> has-range: root.range-from >= 0 && root.range-to >= 0;
/// Which end is under the finger: 0 neither, 1 the early end, 2 the late.
property <int> grabbed: 0;
/// The ends as the drag has them, before Rust has been told. Drawing from
/// these is what lets the band follow the finger without the grid being
/// re-queried on every frame.
property <float> live-from;
property <float> live-to;
property <float> shown-from: root.grabbed > 0 ? root.live-from : root.range-from;
property <float> shown-to: root.grabbed > 0 ? root.live-to : root.range-to;
/// How near a press has to land to take hold of an end, as a fraction of
/// the track. Sized for a finger rather than a pointer: this control
/// exists because on a phone the range could only be typed (FR-UI-2).
property <float> grab: 22px / root.track-height;
/// 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);
}
/// Where a fraction sits on the track. The inverse of `fraction-at`, and
/// it has to stay so: the band is drawn with this and dragged with that.
function y-at(f: float) -> length {
return root.track-top + clamp(f, 0.0, 1.0) * root.track-height;
}
/// Which end of the range a press at `y` takes hold of, 0 for neither.
///
/// Compared in fractions rather than pixels because the ends are held as
/// fractions — converting them to a y here and back to a fraction on the
/// drag would be a second copy of the same arithmetic to keep in step.
function handle-at(y: length) -> int {
if (!root.has-range) {
return 0;
}
if (min(abs(root.fraction-at(y) - root.shown-from),
abs(root.fraction-at(y) - root.shown-to)) > root.grab) {
return 0;
}
// A range narrowed to a day draws one line, not two. Taking the
// nearer end there would always answer "the early one" and leave the
// late end impossible to pull back out; which side of the line the
// press landed on is what the user meant by it.
if (abs(root.shown-from - root.shown-to) < 0.001) {
return root.fraction-at(y) > root.shown-from ? 2 : 1;
}
return abs(root.fraction-at(y) - root.shown-from)
< abs(root.fraction-at(y) - root.shown-to) ? 1 : 2;
}
/// Move the held end to `y`.
///
/// The ends are allowed to cross. Dragging one past the other is a normal
/// way to say "no, from *here* instead", and Rust puts the pair back in
/// order — stopping the finger dead at the other end would make a range
/// that has been narrowed too far harder to correct than to start again.
function drag-end(y: length) {
if (root.grabbed == 1) {
root.live-from = root.fraction-at(y);
} else {
root.live-to = root.fraction-at(y);
}
}
// 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.y-at(root.current-fraction)
: root.track-top + root.track-height / 2;
}
// --- the chosen date range ------------------------------------------
//
// Drawn *on* the axis, and dragged there. The two typed date fields under
// the filter chips were the only way to state a range, and on a phone they
// were barely a way at all: `YYYY-MM-DD` keyed into a 108px field behind a
// soft keyboard, to name a day that is already drawn on the axis a thumb
// away. The other route — zoom the axis to a period, then "limit to range"
// — needs a wheel to zoom and a middle button to pan, and a touch screen
// has neither.
//
// The axis keeps its full extent while a range is on (see
// `refresh_timeline`, and `Filter::without_date_range`). It has to: the
// bars outside the band are what the range is widened back *into*, and an
// axis that redrew itself to the band would move the ground under the very
// handles doing the narrowing.
if root.has-range: Rectangle {
// Outside the range, held back rather than hidden — those bars are
// still the shape of the library, and still where the band is going
// next. Starts at the track, so the header line stays legible.
Rectangle {
x: 0;
y: root.track-top;
width: parent.width;
height: max(0px, root.y-at(min(root.shown-from, root.shown-to)) - root.track-top);
background: Theme.surface;
opacity: 0.72;
}
Rectangle {
x: 0;
y: root.y-at(max(root.shown-from, root.shown-to));
width: parent.width;
height: max(0px, parent.height - self.y);
background: Theme.surface;
opacity: 0.72;
}
// The ends themselves: a line across the axis with a grip at the left,
// where the bars start. The right-hand strip carries the year and
// month labels, and a grip there would sit on top of them.
for edge[i] in [0, 1]: Rectangle {
x: 0;
y: root.y-at(i == 0 ? root.shown-from : root.shown-to) - 1px;
width: parent.width;
height: 2px;
background: Theme.active;
Rectangle {
x: 2px;
y: -5px;
width: 18px;
height: 12px;
border-radius: 3px;
background: Theme.active;
}
}
}
// 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 (root.grabbed > 0) {
// The band follows the finger. The grid hears about it on
// release — see `range-changed`.
root.drag-end(self.mouse-y);
} else 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;
// An end of the range first, if the press landed on one: a
// press within a finger's width of a handle means the handle,
// not the scrub it would otherwise have been.
root.grabbed = self.panning ? 0 : root.handle-at(self.mouse-y);
if (root.grabbed > 0) {
root.live-from = root.range-from;
root.live-to = root.range-to;
} else if (!self.panning && root.bars.length > 0) {
root.scrub-to(root.fraction-at(self.mouse-y));
}
}
if (e.kind == PointerEventKind.up) {
self.panning = false;
if (root.grabbed > 0) {
root.grabbed = 0;
root.range-changed(root.live-from, root.live-to);
}
}
// A cancelled gesture — the system taking the touch for a
// back-swipe, most often — is not a range the user stated. The
// band goes back to where Rust still has it.
if (e.kind == PointerEventKind.cancel) {
self.panning = false;
root.grabbed = 0;
}
}
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,
// The cell a shift-click would extend *from* — the last one plainly
// clicked. Marked because otherwise the gesture has a hidden operand: the
// user picks the far end of a run with no way to see which end the range
// is being measured from, and a range that comes out wrong gives no clue
// why. At most one cell in the library carries this, and often none of
// them is on screen — which is itself the answer to "why did that select
// so much".
anchor: 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,
}
// The photo roll: the grid's loaded window along the foot of the develop view.
//
// # Why this exists
//
// Develop opens *one* photograph. `index` and `total` are pinned to "1 of 1"
// on the way in, because the grid hands over a path and nothing else, so the
// only way to reach the next frame was to go back to the library, find where
// you were, and tap again. That is fine once and intolerable through a set of
// forty — which is precisely the situation the develop view exists for.
//
// # The gesture
//
// A swipe up from the bottom edge brings it out, a swipe down puts it away:
// the sheet gesture, already in the hands of anyone who has used a phone.
// A gesture with no visible counterpart is a feature only its author knows
// about (FR-UI-4), so a handle is drawn at the edge and is a button in its own
// right — which is also what gives a pointer, with no swipe to make, a way in.
//
// The handler wraps the strip rather than sitting over or under it. That is
// what `SwipeGestureHandler` is built for: it delays a press the way a
// Flickable does, forwards it to the children if no swipe develops, and claims
// it once one does. A tap therefore reaches the thumbnail and a drag does not.
//
// It covers only the band along the bottom, never the whole canvas — above
// that band a drag belongs to the photograph, for panning and for the crop.
export component PhotoRoll inherits Rectangle {
in property <[LibraryCell]> cells;
/// Which row of the loaded window is open, so it can be marked. `-1` when
/// the open photograph is not in the window at all, which is the honest
/// answer after a scrub — the roll shows where you are, and sometimes the
/// answer is "not here".
in property <int> current: -1;
in-out property <bool> open: false;
/// A thumbnail was chosen. The row within the loaded window, matching what
/// a cell click reports.
callback pick(int);
property <length> strip-height: 108px;
/// The band of canvas left grabbable when the roll is away. A thumb's
/// worth, and no more: it is taken off the bottom of the photograph.
property <length> reach: 28px;
property <length> thumb: 92px;
property <length> pad: 6px;
background: transparent;
swipe := SwipeGestureHandler {
width: 100%;
height: root.strip-height + root.reach;
y: parent.height - self.height;
handle-swipe-up: !root.open;
handle-swipe-down: root.open;
// Direction decides, not distance: the handler has already applied its
// own threshold by the time this fires, and re-testing the travel here
// would mean a swipe that qualified as a swipe still did nothing.
swiped => {
root.open = self.current-position.y < self.pressed-position.y;
}
// --- the strip ------------------------------------------------
//
// Slid out of view rather than removed. An `if` would have it appear
// fully formed at the bottom of the screen instead of arriving from
// the edge, and would leave nothing for the animation to act on.
strip := Rectangle {
width: 100%;
height: root.strip-height;
y: root.open ? root.reach : parent.height;
animate y { duration: 180ms; easing: ease-out; }
background: Theme.surface;
clip: true;
Rectangle {
width: 100%;
height: 1px;
background: Theme.rule;
}
roll := Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width:
max(self.width,
root.cells.length * (root.thumb + root.pad) + root.pad);
// Bring the open photograph into view — on opening, and when
// the roll itself moves the selection along. Without it a pick
// near the end of the window scrolls back to the start on the
// next reveal, and the mark the strip exists to show is off
// the edge of it.
function reveal() {
if (root.current < 0) {
return;
}
let left = root.pad + root.current * (root.thumb + root.pad);
let shown = -self.viewport-x;
let right = max(0px, self.viewport-width - self.width);
if (left < shown) {
self.viewport-x = -min(right, left);
} else if (left + root.thumb > shown + self.width) {
self.viewport-x =
-min(right, left + root.thumb - self.width);
}
}
property <int> mark: root.current;
changed mark => { self.reveal(); }
property <bool> shown: root.open;
changed shown => {
if (self.shown) { self.reveal(); }
}
for cell[i] in root.cells: Rectangle {
x: root.pad + i * (root.thumb + root.pad);
y: root.pad;
width: root.thumb;
height: parent.height - 2 * root.pad;
background: Theme.ground;
border-radius: Theme.radius;
// The one that is open, marked the way the grid marks a
// selection so the two read as the same idea.
border-width: i == root.current ? 2px : 0px;
border-color: Theme.selected-ring;
clip: true;
Image {
width: 100%;
height: 100%;
source: cell.thumbnail;
image-fit: contain;
visible: cell.has-thumb;
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.pick(i); }
}
}
}
}
// --- the handle -----------------------------------------------
//
// Rides on the strip's top edge, so it is in the same place relative to
// the roll whether it is in or out, and it is the affordance that stops
// the swipe being folklore.
Rectangle {
width: 84px;
height: root.reach;
x: (parent.width - self.width) / 2;
y: strip.y - self.height;
animate y { duration: 180ms; easing: ease-out; }
background: Theme.surface;
border-radius: Theme.radius;
Rectangle {
width: 32px;
height: 3px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.ink-dim;
border-radius: 2px;
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.open = !root.open; }
}
}
}
}
// 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();
/// TRACES: FR-CAT-5
/// Open the sheet that keywords the selection.
callback add-keyword();
callback toggle-select-mode();
callback change-library();
callback toggle-pin-scope();
callback sync-now();
/// TRACES: FR-CAT-10
/// Whether this platform can reach a card at all (`dr_plat::imports_supported`).
///
/// Hidden rather than disabled, unlike the buttons above that come and go
/// with the selection: those are unavailable *now* and will be available
/// in a moment, where this one never will be on this device. A permanently
/// disabled control teaches the reader that the row lies.
in property <bool> can-import: false;
callback rescan();
callback open-import();
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-CAT-5 | FR-CAT-6
// Keyword the selection. Beside "Add to collection" because they are the
// same thought — these photographs are *of* something, and they belong
// *with* something — and appearing under the same condition, because
// neither means anything without a selection to act on.
if root.selected-count > 0: Button {
text: "Keywords";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.add-keyword(); }
}
// 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(); }
}
// TRACES: FR-CAT-10
// Beside Rescan rather than beside Settings: both put photographs into the
// library, where Settings is about the application. Hidden during a scan
// for the same reason Rescan is — an import writes files the running scan
// would then half-see, and the two disagreeing about what is in a folder
// is a worse outcome than waiting.
if !root.scanning && root.can-import: Button {
text: "Import";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-import(); }
}
// 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);
/// A pinch step: the ratio since the last update, above 1 spreading. The
/// continuous counterpart of `zoom-cells`, which the wheel steps.
callback pinch-cells(float);
/// A pinch has begun, so the press that opened it was not a press.
callback pinch-started();
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);
/// How many cells the viewport can show at once changed — a resize, or a
/// column-count change.
///
/// The *screenful*, not the window to load. How many screenfuls are held
/// around it, and how close the view may come to an edge of them before
/// the window moves, are one decision and it is made in Rust — split
/// across the two languages they drifted apart, and the gap between them
/// was rows on screen that no loaded cell covered.
callback viewport-cells-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.
/// TRACES: FR-CAT-10
/// Whether this platform can import at all. Forwarded to the header, which
/// hides the button rather than disabling it.
in property <bool> can-import: false;
callback open-import();
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();
/// TRACES: FR-CAT-5
/// Drop the selection without leaving select mode.
callback clear-selection();
/// TRACES: FR-CAT-5
/// Make a new collection holding exactly what is selected.
callback collection-from-selection();
/// 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();
/// Whether the grid is narrowed to the timeline's visible span.
in property <bool> range-active: false;
callback toggle-date-range();
/// TRACES: FR-CAT-6
/// The same range as fractions of the span the axis is drawn over, for
/// the band on the timeline. Negative in both when there is none.
///
/// A second representation of one range, which is worth it: the fields
/// below are how a date is *stated* and the band is how a period is
/// *found*, and neither is a good way to do the other's job. Both come
/// from the one filter in Rust, so they cannot drift apart.
in property <float> range-from-fraction: -1;
in property <float> range-to-fraction: -1;
callback timeline-range-changed(float, float);
/// The ends of the range, as `YYYY-MM-DD`, when one is set.
///
/// Strings rather than instants because this is what the user types and
/// what they read back. Rust parses them and refuses what is not a date;
/// `range-invalid` is how that refusal reaches the field, since a filter
/// that silently ignored a typo would show an empty grid and no reason.
in property <string> range-from;
in property <string> range-to;
in property <bool> range-invalid: false;
/// Both ends at once: they are one range, and applying half of an edit
/// would filter to a span the user never asked for.
callback range-edited(string, string);
/// 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);
// --- keywording the selection (FR-CAT-5, FR-CAT-6) ----------------------
//
// The catalog has been searchable by keyword since it existed and there was
// never anywhere to type one. This sheet is that place, and it sits beside
// the filing sheet above because the two are the same gesture applied to
// two different kinds of label — pick the photographs, then say what they
// are — and a user who has learnt one should not have to learn the other.
//
// Assign and unassign travel by **name**, not by id. A word typed into the
// field and a word tapped in the list are then one path through Rust rather
// than two, and the sheet does not have to invent an id for a keyword that
// does not exist yet.
/// The vocabulary, already answered against the current selection.
in property <[KeywordRow]> keywords;
/// The sheet is opening: Rust answers by refreshing `keywords` against
/// whatever is selected *now*.
///
/// Pulled on open rather than pushed on every selection change, because the
/// selection changes on every arrow key and the sheet is shut for almost
/// all of them — recomputing coverage over a forty-image selection for a
/// panel nobody is looking at is work the grid cannot afford.
callback keywords-opened();
callback assign-keyword(string);
callback unassign-keyword(string);
/// Whether the sheet is up. Local, for the same reason `filing` is: it is a
/// disclosure rather than a preference, and what closes it is dismissing it.
property <bool> keywording: false;
// 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.
/// The size class the user chose. What is actually drawn is `cell-size`
/// below, which is this capped to what the grid can hold.
in property <length> requested-cell-size: 180px;
// --- what the grid is measured against ---------------------------------
//
// **`grid-area`, not `self`.** Every quantity below describes the box the
// cells are actually drawn in, and that box is *not* this component: the
// capture-time axis is a sibling of it, 96px wide, and the header sits
// above it. Measuring against `self.width` therefore counted the timeline
// as room for thumbnails and fitted one more column than there was space
// for — the last column ran off the right-hand edge, clipped by the
// Flickable, with no way to scroll sideways to it. At 180px cells that is
// a whole column lost on a phone in portrait, where 96px of 400 is a
// quarter of the screen.
//
// Reading a descendant's geometry is safe here because nothing derived
// from it feeds back into the layout: the cells are placed absolutely
// inside the Flickable, and a Flickable's own layout constraints are a
// bare `stretch: 1` that its viewport cannot influence. That is the loop
// the comments on `panel-visible` in app.slint warn about, and this is not
// an instance of it.
/// How many columns of roughly the requested size the grid holds.
///
/// **Rounded, not floored.** The size class is a request, not a
/// measurement — what the user is choosing is "about this big" — so a
/// width that is nine tenths of the way to another column should take it
/// rather than leave nearly a whole column of dead space at the edge.
///
/// A minimum of one, so a grid narrower than a single cell still draws
/// that cell rather than none.
property <int> columns:
max(1, round((grid-area.width - Theme.gap)
/ (root.requested-cell-size + Theme.gap)));
/// The drawn cell: the width, divided exactly.
///
/// **The tiles fill the grid.** They used to be drawn at whatever the size
/// class asked for and the remainder left as a bare strip down the
/// right-hand side — up to one cell short of a full column of nothing,
/// which on a phone is a quarter of the screen. Worse, the column count
/// was derived from that same fixed size, so the two disagreed about how
/// much room there was and the last column could start inside the viewport
/// and end outside it.
///
/// Solving for the cell instead removes both faults at once: pick how many
/// columns to draw, then make them share the width. `columns` cells and
/// the `columns + 1` gaps around them come to exactly `grid-area.width`,
/// so there is no remainder to strand and nothing can overhang.
///
/// The size class still decides what the user gets — it is what `columns`
/// is chosen from — it just no longer dictates the pixel, so the cell
/// flexes by a few percent either way to make the row come out even.
property <length> cell-size:
max(64px,
(grid-area.width - Theme.gap * (root.columns + 1)) / root.columns);
// 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));
// --- while a pinch is happening, and just after -----------------------
//
// **A pinch does not end cleanly.** Lifting one finger of two leaves the
// other one down, and Slint replays that survivor as a *fresh* `Pressed`
// on whatever is under it — that is how it hands the pointer back to
// ordinary handling. Under it is a cell. So the cell was selected, and
// then lifting that last finger was a complete, well-formed click and the
// photograph opened. Checking the finger id, which is what stops the
// *second* finger's synthetic release from opening anything, cannot help
// here: this press and this release are genuinely the same finger.
//
// Nothing in the event stream distinguishes that survivor from a real tap,
// so the grid has to remember that a pinch just happened. The latch is
// raised when the gesture starts and lowered a beat after it ends.
// --- has this session been touched? -----------------------------------
//
// **Hover is not a thing a finger does, but Slint reports it anyway.**
// `has-hover` goes true for any pointer event carrying a position, a touch
// press included, and false again on the `Exit` that follows the release.
// So on a tablet the rating strip did appear — for exactly the length of a
// tap. It flashed on under the finger, vanished as it lifted, and the tap
// went through to the cell and opened the photograph. There was no way to
// rate an unjudged frame from the grid at all.
//
// The strip therefore stops keying off hover as soon as there is evidence
// that this is a touch session, and evidence is what `touch-finger-id`
// is: zero for a mouse, never zero for a finger. Latched rather than
// sampled per event, because the strip has to be on screen *before* the
// finger arrives to be worth aiming at.
//
// One-way on purpose. A tablet with a mouse plugged in keeps the strips
// once it has been touched, which is the harmless direction to be wrong
// in — the alternative is chrome that comes and goes as the user changes
// hands.
//
// Seeded by Rust as well as latched here, because the latch alone needs a
// press to reach a cell and a quick flick never delivers one — the
// Flickable claims the gesture before the delay it forwards after. On
// Android touch is not evidence to be gathered, it is the platform, so it
// starts true there and this is left to catch a touchscreen on the desktop.
in-out property <bool> touched: false;
property <bool> pinching: false;
settle := Timer {
interval: 350ms;
running: false;
triggered => {
root.pinching = false;
self.running = false;
}
}
// How tall a screenful is, in rows. Measured rather than fixed: the same
// constant is simultaneously too small on a maximised 4K window and
// wasteful on a narrow one, and everything the loaded window does is
// expressed as a multiple of this.
property <int> visible-rows:
max(1, ceil(grid-area.height / (cell-size + Theme.gap)));
/// Cells the viewport shows at once, counting the row the scroll position
/// has cut in half.
///
/// `visible-rows` is a ceiling on a grid whose first row starts at the top
/// of the viewport, and the grid is only ever aligned like that at rest at
/// the very top. Scrolled anywhere else, a part-row hangs off each end and
/// the viewport touches one row more than that — the row this used to
/// undercount is the bottom one, which is the row reported missing.
property <int> viewport-cells: root.columns * (root.visible-rows + 1);
changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); }
/// 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;
// Scrolls rather than overflowing, for the reason set out on the
// filter row below: a layout given less width than its children
// need does not shrink them, it overruns *and* reports the
// oversized minimum upwards — which is how a header of buttons
// ended up dictating how many columns of thumbnails the grid
// thought it could draw. Seven action buttons and four readouts do
// not fit across a tablet even in landscape, which is why the last
// one was clipped by the screen edge.
//
// Sized to `min-width`, not `preferred-width`: the latter is the
// row's *untruncated* footprint — every readout at its natural
// text width — so the Flickable was always at least that wide and
// the row never actually shrank, it just scrolled, with the
// sidebar toggle and the "More" disclosure off past the right
// edge on anything narrower than a tablet. `min-width` is what
// the row needs once its `elide` + `horizontal-stretch: 1`
// readouts (image count, selection count, scan status, the
// window label below) are collapsed to an ellipsis, so a window
// with room for that shrunk row now gets the row shrunk to fit
// instead of scrolled — and the toggle and disclosure, which
// don't shrink, stay on screen. Only a window too narrow even for
// the shrunk row still scrolls.
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, header-row.min-width);
header-row := HorizontalLayout {
width: parent.viewport-width;
height: parent.viewport-height;
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;
}
// `elide` + `horizontal-stretch: 1`, like `window-label` below:
// stretch is what actually lets a `Text` shrink under pressure
// in Slint, not `elide` alone (see the comment on the header
// row's Flickable) — without it these three readouts kept
// their full natural width and were what pushed the sidebar
// toggle and the "More" button past the right edge on a
// narrow window, reachable only by knowing to flick-scroll.
Caption {
text: root.total > 0 ? root.total + " images" : "";
overflow: elide;
horizontal-stretch: 1;
}
// 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;
overflow: elide;
horizontal-stretch: 1;
}
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);
overflow: elide;
horizontal-stretch: 1;
}
// 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;
can-import: root.can-import;
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;
}
add-keyword => {
// Ask for the vocabulary before showing the sheet, so
// it is answered against the selection as it stands now
// rather than as it stood when the grid last loaded.
root.keywords-opened();
root.keywording = true;
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-import => { root.open-import(); }
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;
// Scrolls, like the two rows above it. This is the row that exists
// *because* the header did not fit, so it is the last place to
// assume six buttons will.
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, actions-row.preferred-width);
actions-row := HorizontalLayout {
width: parent.viewport-width;
height: parent.viewport-height;
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;
can-import: root.can-import;
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;
}
add-keyword => {
// Ask for the vocabulary before showing the sheet, so
// it is answered against the selection as it stands now
// rather than as it stood when the grid last loaded.
root.keywords-opened();
root.keywording = true;
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-import => { root.open-import(); }
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;
// --- the chips scroll rather than overflowing --------------
//
// **A layout cannot be narrower than its children's minimums.**
// Given less room than they need, Slint lays them out at their
// minimums and lets the row run past the edge — and, worse, the
// row reports that oversized minimum upwards. This row is a
// sibling of the grid inside one VerticalLayout, so its minimum
// became the *whole view's* minimum: `LibraryGrid` was laid out
// wider than the window, the grid measured itself against that
// inflated box, and the right-hand column was computed to fit in
// space that was off the screen. Fourteen chips do not fit across
// 768 logical pixels, so that is every tablet in portrait.
//
// A Flickable's own minimum is nothing — it is built to be smaller
// than what it holds — so wrapping the row both stops it inflating
// anything and makes the chips past the edge reachable instead of
// merely absent.
Flickable {
width: 100%;
height: 100%;
// Horizontal only: there is one row of chips and it must not
// drift vertically inside a 34px strip.
viewport-height: self.height;
viewport-width: max(self.width, chips.preferred-width);
chips := HorizontalLayout {
width: parent.viewport-width;
height: parent.viewport-height;
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 { width: Theme.gap; }
// Turn the range on, over the span the timeline is showing.
// The histogram is already how you find a period, so this is
// a starting point rather than the answer: the band it puts on
// the axis is then dragged to the fortnight you meant, which
// is the one way in that needs neither a keyboard nor a wheel.
// Pressing again lifts the range.
//
// The histogram deliberately keeps drawing the full extent
// while this is on, or there would be nothing left to widen
// back out from.
FilterChip {
label: root.range-active ? "Date range ×" : "Limit to range";
active: root.range-active;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-date-range(); }
}
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;
}
}
// --- what is selected, and what to do with it --------------------
//
// The count and these actions used to live only in the header row,
// which scrolls sideways: on a tablet "12 selected" and everything
// beside it sat past the right-hand edge, so a selection was something
// you could make and then not see. A selection you cannot see is one
// you act on by accident.
//
// Its own strip, like the date range's, appearing only while there is
// a selection — so it costs nothing the rest of the time, and when it
// is there it has the width of the window rather than the tail of a
// row that did not fit.
if root.selected-count > 0: Rectangle {
height: 40px;
background: Theme.surface;
HorizontalLayout {
width: 100%;
height: 100%;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// State rather than a label: it is what the buttons beside it
// act on.
Value {
text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// Filing a selection into a collection that does not exist yet
// took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how
// a selection is usually meant.
Button {
text: "New collection from selection";
primary: true;
y: (parent.height - self.height) / 2;
clicked => { root.collection-from-selection(); }
}
}
}
// --- the date range's ends ---------------------------------------
//
// Its own strip, a sibling of the chips rather than a child of them.
//
// Twice now this control has looked broken. First it narrowed to the
// whole library, because it took its span from a timeline zoom that
// is zero until someone zooms. Then the fields that fixed that went
// into the chip row — which is 34px tall and scrolls sideways, so
// they were both clipped and off past the right-hand edge.
//
// They are no longer the only way to state a range — the band on the
// axis is, and on a phone it is the only usable one. These stay for
// the two things dragging cannot do: name an exact day, and say what
// the range currently is in words rather than as a position.
//
// A `Rectangle` stacks its children at the origin rather than laying
// them out, which is why putting a second row inside the chips' one
// drew it over them instead of under them. This is a row of the
// header's `VerticalLayout`, so it gets a line of its own and the
// width of the window.
if root.range-active: Rectangle {
height: 40px;
background: Theme.surface;
HorizontalLayout {
width: 100%;
height: 100%;
// The same leading inset the chips above use, so the two rows
// start on one vertical line rather than a few pixels apart.
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
// Fixed-width fields and a two-letter word: left-aligned, so
// they sit under the chip that turned them on instead of
// spreading across the window.
alignment: start;
Field {
width: 108px;
y: (parent.height - self.height) / 2;
text: root.range-from;
placeholder: "YYYY-MM-DD";
accepted(t) => { root.range-edited(t, root.range-to); }
}
Caption {
text: "to";
vertical-alignment: center;
}
Field {
width: 108px;
y: (parent.height - self.height) / 2;
text: root.range-to;
placeholder: "YYYY-MM-DD";
accepted(t) => { root.range-edited(root.range-from, t); }
}
if root.range-invalid: Caption {
text: "not a date";
warn: true;
vertical-alignment: center;
}
}
}
// --- 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 `viewport-cells`, 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;
range-from: root.range-from-fraction;
range-to: root.range-to-fraction;
range-changed(a, b) => { root.timeline-range-changed(a, b); }
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); }
}
grid-area := 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.keywording) {
root.keywording = false;
return accept;
}
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.
//
// Sized to the **viewport**, like `zoom-catcher` above and for
// the same reason. `100%` inside a Flickable is the Flickable's
// own height, and the pinch is delivered to whatever lies under
// the midpoint of the two fingers — so a handler one screenful
// tall sat at the top of a viewport thousands of rows long and
// was under the fingers only while the grid had not been
// scrolled. Anywhere else the gesture found nothing to land on.
grid-pinch := ScaleRotateGestureHandler {
width: 100%;
height: parent.viewport-height;
property <float> last-scale: 1.0;
started => {
self.last-scale = 1.0;
root.pinching = true;
settle.running = false;
// The finger that opened this gesture landed on a cell
// and selected it. It was reaching for the grid, not
// for that photograph.
root.pinch-started();
}
// Continuous, not stepped. Thresholding this into ±1 zoom
// steps meant the grid lurched 25% at a time and sat still
// in between, which is the whole of "pinching is not
// smooth". The ratio since the last update is what tracks
// the fingers; where the drawn cell lands is still a whole
// number of columns, because the columns divide the width.
updated => {
root.pinch-cells(self.scale / max(0.01, self.last-scale));
self.last-scale = self.scale;
}
// The latch outlives the gesture — see `pinching`.
ended => {
self.last-scale = 1.0;
settle.running = true;
}
cancelled => {
self.last-scale = 1.0;
settle.running = true;
}
}
// 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;
// TRACES: FR-CAT-7
// Stay inside the content when the content shrinks.
//
// Deleting photographs makes the library shorter, and the
// viewport is sized to the *whole* library — so a view that was
// scrolled near the end is suddenly scrolled past it. Slint
// does not pull a Flickable back on its own, so the grid went
// blank: the cells were still there, above a viewport looking
// at empty space below them.
//
// Worse than blank, it also *moved*. Cells are drawn at
// `(i + offset) / columns`, and a delete re-clamps `offset`
// downward to keep the window full — so the same `viewport-y`
// now points at a different part of the library, and the grid
// appeared to jump somewhere arbitrary. Re-seeking below is the
// other half of this; this half stops the blank.
changed viewport-height => {
if (-self.viewport-y > max(0px, self.viewport-height - self.height)) {
self.viewport-y = -max(0px, self.viewport-height - self.height);
}
}
// 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;
}
}
// The end a shift-click measures from. An inner ring rather
// than a colour of its own: it has to be legible against a
// thumbnail of any brightness and must not read as a second
// kind of selection, which a hue would.
Rectangle {
visible: cell.anchor;
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
background: transparent;
border-width: 1px;
border-color: Theme.selected-ring;
border-radius: Theme.radius-sm;
}
// 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.
// --- which finger, and whether it finished --------
//
// A pinch begins as an ordinary press. When the second
// finger lands Slint closes the first one's gesture by
// synthesising a `Released` at its position — that is
// how a Flickable is made to let go of a scroll it had
// already claimed. A TouchArea cannot tell that release
// from a real one and fires `clicked`, so every attempt
// to pinch-zoom the grid opened whichever photograph
// the first finger happened to be resting on.
//
// The finger id is what separates them: the synthetic
// release carries the id of the finger that *arrived*,
// never the one that pressed. A mouse reports 0 for
// both, so the desktop path is unchanged.
//
// `clicked` fires before the `up` that follows it, so
// it can only raise a flag — the decision to open has
// to wait for the event that names the finger.
property <int> down-finger: -1;
property <bool> click-pending: false;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.down-finger = ev.touch-finger-id;
self.click-pending = false;
// A finger, not a pointer — see `touched`.
if (ev.touch-finger-id != 0) {
root.touched = true;
}
// Not while the grid is being pinched, nor in
// the moment after: this is the finger left
// over from the gesture, handed back as a new
// press. See `pinching`.
if (!root.pinching) {
root.cell-pressed(
i,
ev.modifiers.control || root.select-mode,
ev.modifiers.shift,
);
}
}
if (ev.kind == PointerEventKind.up) {
// 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 here, so the press above
// records it and Rust decides — `cell-clicked`
// is only honoured when the press was
// unmodified.
if (self.click-pending
&& !root.pinching
&& ev.touch-finger-id == self.down-finger) {
root.cell-clicked(i);
}
self.click-pending = false;
root.cell-press-ended();
}
// `cancel` is the important ending: 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.cancel) {
self.click-pending = false;
root.cell-press-ended();
}
}
clicked => { self.click-pending = true; }
// 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, INSIDE the cell's hit area ---
//
// **A child of `cell-touch`, not a sibling of it.** Two
// things have to be true at once here, and only this
// nesting gets both.
//
// A star must take the click without it also reaching
// `cell-clicked`, or every rating throws the user into
// develop. Children are hit-tested before the element
// they sit in, and a child that accepts ends the walk
// before the TouchArea's own handler runs — so the star
// wins, and it wins for the same reason a later sibling
// used to.
//
// And the strip must not vanish as the pointer arrives
// at it. As a *sibling* it did: hover is tracked per
// TouchArea, Slint sends `Exit` to whatever drops out of
// the hit path, and the strip taking the pointer dropped
// `cell-touch` out of it. `has-hover` went false, which
// took `show-empty` with it, which hid the very stars
// the pointer was travelling towards — on an unrated
// cell they disappeared, the click landed on the cell
// behind them, and the image opened. That is the whole
// of the "stars vanish when I click one" report.
//
// An *ancestor* stays in the path: it keeps its place on
// the item stack, gets no `Exit`, and during a grab it is
// handed only the filter and never the event. So hover
// holds for as long as the pointer is anywhere in the
// cell, stars included.
//
// 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. That trade only
// works where there is a pointer to hover with: on
// touch the strip stands open, because a control
// that appears under the finger is a control that
// appears too late to aim at. See `touched`.
show-empty: cell-touch.has-hover || root.touched;
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; }
}
}
}
}
// --- the keywording sheet (FR-CAT-5, FR-CAT-6) --------------------------
//
// "These are of…". Deliberately the same card, scrim and dismissal as the
// filing sheet above: a user who has filed a selection already knows how
// this works, and a second idiom for the same gesture would be a second
// thing to learn for no gain.
//
// It stays open after each word, where the filing sheet closes. Filing is
// one choice; keywording is usually several — "puffin", "Látrabjarg",
// "2026" — and a sheet that shut after each one would have to be reopened,
// and the selection re-confirmed, three times over.
if root.keywording: 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.keywording = false; }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: min(kw-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 { }
kw-sheet := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: root.selected-count == 1
? "Keywords for 1 photograph"
: "Keywords for " + root.selected-count + " photographs";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
// Typing a word applies it, whether or not it already exists.
// One field for both, because "is this keyword new?" is a
// question about the catalog and not about what the user meant,
// and Rust can answer it without being asked.
//
// The field clears itself on accept so the next word can be
// typed straight after — keywording a shoot is a run of them.
new-keyword := Field {
placeholder: "Type a keyword and press return";
accepted(text) => {
root.assign-keyword(text);
self.text = "";
}
}
Rectangle { height: 1px; background: Theme.rule; }
Flickable {
vertical-stretch: 1;
// A floor, so the list is not squeezed out of existence by
// the field and the button around it on a short window.
min-height: 120px;
viewport-height: root.keywords.length * (Theme.touch-target + 2px);
for word[i] in root.keywords: Rectangle {
y: i * (Theme.touch-target + 2px);
width: parent.width;
// A full touch target per row, for the same reason the
// filing sheet uses one: 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: kw-touch.pressed ? Theme.pressed
: (kw-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
// Tick, dash, or nothing — the three states of
// `coverage`, drawn as three different marks rather
// than as two. A half-applied keyword shown as
// applied is a lie about photographs the user
// cannot see from here.
Rectangle {
width: 16px;
y: (parent.height - self.height) / 2;
height: 16px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: word.coverage == 0 ? Theme.rule : Theme.active;
background: word.coverage == 2 ? Theme.active : transparent;
// The dash for "some of them". A bar rather
// than a tick, because a tick at half strength
// reads as a rendering artefact.
if word.coverage == 1: Rectangle {
width: 8px;
height: 2px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.active;
}
if word.coverage == 2: Icon {
name: "check";
ink: Theme.surface;
size: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
Text {
text: word.name;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
// "3 of 12" only where it says something the mark
// does not. For a word the whole selection carries,
// or none of it, the mark has already said it and
// the number would be noise on every row.
Text {
text: word.coverage == 1
? word.selected-count + " of " + root.selected-count
: (word.image-count > 0 ? word.image-count + "" : "");
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
// One target for both directions. A word the selection
// fully carries comes off; anything else goes on — so a
// partly-applied keyword is completed rather than
// removed, which is what a user tapping a dash means
// nine times in ten, and the tenth is one more tap
// away.
kw-touch := TouchArea {
clicked => {
if (word.coverage == 2) {
root.unassign-keyword(word.name);
} else {
root.assign-keyword(word.name);
}
}
}
}
if root.keywords.length == 0: Text {
text: "No keywords yet. Type one above to make the first.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
width: parent.width;
}
}
Rectangle { height: 1px; background: Theme.rule; }
Button {
text: "Done";
clicked => { root.keywording = false; }
}
}
}
}
}