Files
DarkRoom/ui/dr-ui/ui/library.slint
T
dtourolle 14ac41bee0 Give the keyword sheet's field focus, and the empty trash its own words
Without focus the keys typed into the keywords sheet went to the grid
behind the scrim, and the Return meant for the keyword opened a
photograph. The naming sheet already takes focus on open; do the same.

An empty trash said "No images found — check the library folder and which
formats are ticked", which sends someone off to fix a library that is
fine.
2026-09-20 00:21:14 +02:00

4476 lines
223 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";
import { GestureSheet, GestureRow } from "gestures.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;
/// Whether the marker has a position to report. False only before the first
/// axis has been built — Rust seeds it from wherever the grid sits, so a
/// launch draws the marker where the photographs on screen are rather than
/// leaving it dimmed at mid-track waiting for a scroll.
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; }
}
}
}
/// One person on the filter bar, in either of the two roles it plays there.
///
/// The chips for who the grid is *currently* narrowed to, and the roster in the
/// tray the user picks from, are the same thing drawn twice — same id, same
/// wording — so they are one struct. `faces` and `picked` are the tray's half
/// and are simply not read by the narrowed-to chips.
export struct PersonChip {
id: int,
name: string,
/// How many faces the library holds of them. `-1` where it was not asked
/// for, which `FilterChip` draws as no number at all rather than as zero.
faces: int,
/// Already one of the people the grid is narrowed to.
picked: bool,
}
export struct LibraryCell {
name: string,
// Non-empty on the first cell of a new month, e.g. "August 2026". The grid
// is ordered by capture time, so these are the only place the date is
// legible without consulting the sidebar — a wall of thumbnails otherwise
// gives no sense of when you are.
period-heading: string,
// Empty until a fetch lands. `has-thumb` disambiguates, because Slint
// cannot test an image against null.
thumbnail: image,
has-thumb: bool,
// A fetch that completed with no usable preview. Distinct from pending.
unavailable: bool,
// Part of the current selection. Selection is what a drag carries, so this
// has to be per-cell state rather than a single "current" index.
selected: bool,
// How many collections this image belongs to. An image can be in many at
// once, and without a cue the grid gives no hint that a photograph has
// already been filed — the user re-files it, or hunts for where it went.
collection-count: int,
// This cell is one of the images currently being dragged. It reads as
// *lifted out*: desaturated and shrunk in place, so the grid shows where
// the photographs came from while the cursor shows them in full colour.
lifted: bool,
// Stars, 0..5. Zero is *unrated* — a state of its own, not a low score,
// and what "filter to unjudged" selects (FR-CULL-4).
rating: int,
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
// four-star frame is a normal thing to do mid-cull.
flag: int,
// Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it
// belongs to none, which is most of a library. A burst that is collapsed
// draws only its representative, so on that cell this is the count of what
// is hidden behind it — the reason it is shown at all.
burst-count: int,
// Whether the group is currently open. Drawn differently rather than
// hidden: a burst the user has expanded is the one thing on screen that
// needs a way back, and a control that disappears once used is a control
// nobody finds twice.
burst-expanded: bool,
// Whether this is the frame the group folds up to — the earliest of them
// until the user says otherwise. Read only while the group is open: folded,
// the one cell on screen is the representative by construction, and a mark
// saying so would be telling the user what they can already see.
burst-representative: bool,
}
// 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 —
// and while the roll is away the band narrows to the handle's own `reach`,
// because a gesture handler consumes every press that lands in it. The note
// on `swipe` below says what that cost before it did.
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;
/// Centre the open photograph the next time the strip settles, instead of
/// merely bringing it into view.
///
/// **A one-shot request, cleared here rather than by Rust**, because the
/// only thing that knows the request has been honoured is the code that
/// honours it. Rust raises it when a photograph is opened *from the grid* —
/// the start of a develop session — and from then on every pick along the
/// roll uses `reveal()`, which moves as little as will show the mark. That
/// is the difference the user asked for: arriving at a photograph should
/// show what is either side of it, and stepping along from there should not
/// keep yanking the strip back to centre.
///
/// It has to be a flag the strip consumes rather than something recomputed
/// on creation, because the strip is created more often than a session
/// begins: leaving develop for Settings and coming back rebuilds it, and
/// re-centring then would undo a roll the user had scrolled by hand.
in-out property <bool> centre-request: false;
/// A thumbnail was chosen. The row within the loaded window, matching what
/// a cell click reports.
callback pick(int);
/// Both from the theme rather than from here, because the canvas
/// positions its floating controls against the band these two describe.
property <length> strip-height: Theme.roll-strip;
/// 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: Theme.roll-reach;
property <length> thumb: 92px;
property <length> pad: 6px;
background: transparent;
swipe := SwipeGestureHandler {
width: 100%;
height: root.strip-height + root.reach;
// **The whole handler slides, and the strip sits still inside it.**
//
// A gesture handler is an input surface, not only a box to hang the
// strip on: while a press lands inside it that press is *taken* — it
// is delayed, then offered to the handler's own children and to
// nothing else, so no element behind it is ever asked. A band the full
// height of the strip is right while the strip is out and is a dead
// zone over the foot of the photograph while it is away, which is
// where the crop's bottom handles and the canvas's floating controls
// live.
//
// So the band goes where the roll goes. Closed, only `reach` of it is
// on screen — the handle, and the thumb's width of canvas that starts
// the swipe; the rest hangs below the window where nothing can press
// it. Open, it covers the strip, which is what lets a swipe down
// anywhere across the thumbnails put the roll away.
//
// This is also why the animation moved here from the strip: the strip
// is now fixed at `reach` within the handler, so the handler's `y` is
// the only thing left that travels.
y: root.open
? parent.height - self.height
: parent.height - root.reach;
animate y { duration: 180ms; easing: ease-out; }
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.
// What travels is the handler around it; see the note on `swipe`.
strip := Rectangle {
width: 100%;
height: root.strip-height;
// Below the handle and no lower: the handler above carries both
// of them in and out.
y: root.reach;
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);
}
}
// Put the open photograph in the middle of the strip, with as
// much of the roll either side of it as there is.
//
// Clamped to the ends rather than centred unconditionally: the
// third photograph of a window cannot be centred without
// scrolling empty space in on the left, and a strip that starts
// with a gap reads as broken rather than as centred.
function centre() {
if (root.current < 0) {
return;
}
let left = root.pad + root.current * (root.thumb + root.pad);
let right = max(0px, self.viewport-width - self.width);
let want = left + root.thumb / 2 - self.width / 2;
self.viewport-x = -min(right, max(0px, want));
}
// Which of the two the strip does, and the only place the
// request is cleared.
//
// **Nothing happens before the strip has a width.** Centring
// divides by it, and `init` runs before layout — so honouring
// the request there would scroll to an arithmetic answer based
// on a width of zero *and* consume the flag, leaving the roll
// wrong with nothing left to correct it. Deferring instead is
// safe because the width arriving is itself a change, and
// `changed width` below settles again once it has.
function settle() {
if (self.width <= 0px) {
return;
}
if (root.centre-request) {
root.centre-request = false;
self.centre();
} else {
self.reveal();
}
}
property <int> mark: root.current;
changed mark => { self.settle(); }
property <bool> shown: root.open;
changed shown => {
if (self.shown) { self.settle(); }
}
// On creation, and again when the strip is first given a
// width. None of the four is redundant.
//
// The strip is rebuilt on every entry to develop, so `mark` and
// `shown` are *initialised* rather than changed and neither
// handler above fires — which is precisely the moment a session
// begins and the request is waiting to be honoured. A roll left
// open from the previous session would otherwise slide in
// showing whatever it showed last.
//
// `init` runs before layout, though, so it usually finds no
// width and defers; `changed width` is what actually honours the
// request a frame later. Both are kept because the order is not
// guaranteed, and a settle that has nothing to do is a no-op.
init => { self.settle(); }
changed width => { self.settle(); }
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;
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; }
}
}
}
}
// TRACES: NFR-A11Y-3
// 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); }
}
}
}
}
// TRACES: NFR-A11Y-3
// 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, a status line and these 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 these 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.
//
// **Nothing in here depends on the selection.** Everything a selection can be
// done *to* lives on the bar at the foot of the grid, which is also where the
// selection is reported. Six buttons used to appear and disappear from the
// middle of this row as photographs were picked, shunting `Sync`, `Settings`
// and everything beside them several hundred pixels sideways at the exact
// moment a hand was already travelling toward one — and splitting "what can I
// do with these?" across two bars at opposite ends of the window. A row that
// only changes when the *library* changes can be aimed at from memory.
//
// Nor does anything in here refer to *one collection*. "Keep offline" and
// "Change library" are in the collections panel, under the tree whose
// selection decides what the first of them acts on and which the second
// replaces wholesale.
component HeaderActions inherits HorizontalLayout {
/// 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;
/// 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 toggle-select-mode();
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 that come and go with a
/// scan: 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();
callback open-people();
/// TRACES: FR-UI-4
/// Open the gesture reference.
callback open-gestures();
spacing: Theme.gap;
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
// The way *into* selecting, and the only selection control left in this
// row — everything a selection can be done *to* is on the bar at the foot
// of the grid, beside the count that says what it would act on.
//
// 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(); }
}
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(); }
}
// A library action rather than a setting, so it sits with the others and
// ahead of Settings, which the comment below keeps last.
Button {
text: "Identity";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-people(); }
}
// TRACES: FR-UI-4
// What the gestures are, for the ones that have no button of their own.
// Beside Settings because it is about the application rather than about
// the photographs, and unconditional for the same reason Settings is: a
// reference you can only reach in some states is one you look for in the
// state where you needed it and do not find.
Button {
text: "Gestures";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-gestures(); }
}
// Last in the row, and unconditional. Nothing before it moves with the
// selection any more, so this is a fixed address rather than a control
// that drifts as photographs are picked.
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;
/// The catalog on this device is being opened and checked before the scan
/// starts. Distinct from `scanning` for the reason the empty state below
/// gives: they are two different waits and only one of them is the network.
in property <bool> opening: 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;
// --- the header's one status line -------------------------------------
//
// Four readouts used to sit in the header beside the title: the image
// count, the selection count, the scan's status and where in the library
// the visible window sits. Each carried `horizontal-stretch: 1` — which is
// what actually lets a `Text` shrink in Slint — so between them they took
// about a third of a 768px header and left the buttons to scroll off the
// end of it.
//
// The selection count is gone from here entirely: it belongs beside the
// buttons that act on it, which is the bar at the foot of the grid.
//
// The other three are one subject — what this grid is showing and what is
// being done to it — so they are one sentence with separators, and one
// stretch between them. Two of them even said the same words while a sweep
// ran: "indexing 300 / 12 480" appeared as the status *and* as the window
// position.
//
// Assembled in two steps rather than one so the separators can be dropped
// along with the parts they follow. A library with nothing scanned and
// nothing loaded should read as empty, not as " · · ".
property <string> status-phrase:
root.scanning ? root.scan-status
: (root.sweeping
? "indexing " + root.sweep-done + " / " + root.sweep-total
: root.scan-status);
property <string> status-line:
(root.total > 0 ? root.total + " images" : "")
+ (root.status-phrase != ""
? (root.total > 0 ? " · " : "") + root.status-phrase
: "")
+ (root.window-label != ""
? ((root.total > 0 || root.status-phrase != "") ? " · " : "")
+ root.window-label
: "");
callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int);
/// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back
/// up. Which of the two is decided in Rust, from what the catalog says the
/// group is currently doing, so the mark cannot get out of step with the
/// query that actually hides the frames.
callback burst-toggled(int);
/// A frame of an open burst was named as the one it folds to (FR-CULL-5),
/// by row. Nothing here decides *which* frame deserves it — the catalog's
/// default is the earliest, a fact about the clock rather than a judgement
/// about the photograph, and this is the photographer overruling it.
callback burst-representative-chosen(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();
/// 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();
callback open-people();
/// TRACES: FR-UI-4
/// The gesture reference's rows, from Rust — which reads them from the
/// generated table. See gestures.slint for why they cannot be written here.
in property <[GestureRow]> gestures;
/// Whether the reference is up. Local, like `naming` and `filing`: nothing
/// in Rust needs to know a sheet is open.
property <bool> helping: false;
// --- 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
/// 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.
// GESTURE: Start selecting several photographs
// where: Library grid
// touch: Press and hold a photograph, or press Select in the header
// pointer: Ctrl-click, or press Select in the header
// why: Touch has no ctrl, so without a mode there is no way to
// select a second photograph — the first tap would open it.
// The hold is the fast way in and the button is the one that
// can be found.
//
// GESTURE: Add or remove one photograph
// where: Library grid
// touch: While selecting, tap it
// pointer: Ctrl-click it
// why: While selecting, a tap never opens. That is the whole point
// of the mode: one meaning per gesture at a time. Press Done to
// get tap-to-open back.
//
// GESTURE: Leave selecting
// where: Library grid
// touch: Press Done in the header
// pointer: Press Done in the header
// keys: Escape
in property <bool> select-mode: false;
callback toggle-select-mode();
/// TRACES: FR-CAT-7 | FR-UI-4
/// The photograph a press has held long enough to pick up, or `-1`.
///
/// **The visible half of a gesture that was folklore.** A finger on a cell
/// is ambiguous — it may be starting a scroll or taking hold of a
/// photograph — and Slint resolves that by giving the `Flickable` the first
/// half-second: any press that travels more than a few pixels vertically
/// inside it becomes a scroll, and the drag never begins. Only a fast
/// sideways flick, or waiting the half-second out, ever picked a
/// photograph up, and nothing on the screen said so. The user's account of
/// it was that dragging "sometimes works".
///
/// So the wait is given a mark. The same hold that turns on selection mode
/// sets this, a ring opens outward around the cell, and from that moment
/// the drag is the only thing the finger can be doing — the grid below is
/// no longer `interactive`, so there is no scroll left to lose to. The cue
/// can only ever arrive *after* the ambiguity has passed, which is the
/// honest direction: once the ring is open, dragging works.
///
/// A row of the loaded window, like every other row here. It is cleared
/// when the press ends and when a drag finishes, and the grid cannot
/// scroll while it is set, so it cannot outlive the window it indexes.
// GESTURE: Pick a photograph up to drag it
// where: Library grid
// touch: Press and hold it until a ring opens around it, then drag
// pointer: Drag it
// why: A finger on a photograph might be starting a scroll, and for
// the first half-second the grid assumes it is. Holding says
// otherwise, and the ring is the grid saying it heard — from
// there the drag cannot be lost to a scroll. A mouse never
// waits: the cursor is precise enough that a sideways drag is
// unambiguous from the first pixel.
in-out property <int> held-row: -1;
/// TRACES: FR-CAT-5
// --- reordering a manual collection (FR-CAT-7) --------------------------
//
// `collection_members.position` and `Sort::CollectionPosition` have existed
// in the catalog since collections did, and nothing above it ever wrote or
// read them: the grid ordered everything by capture time, always. This is
// the gesture that makes the column mean something.
//
// Only where there is a manual order to change — a single manual collection
// with no children. A set interleaves two children's unrelated positions
// and a smart collection has no member rows at all, so both fall back to
// capture time and refuse the drop rather than pretending.
/// Whether a drop on a cell should move photographs within the collection
/// being shown. Set by Rust from the scope, because what a scope *is* is a
/// catalog question.
in property <bool> reorderable: false;
/// Move the selection so it sits beside the photograph at this row of the
/// loaded window — before it, or after it where the drop landed on the
/// cell's trailing half. The trailing half is what makes the last position
/// reachable at all; without it there is no cell to drop "before".
callback reorder-to(int, bool);
/// Drop the selection without leaving select mode.
callback clear-selection();
/// TRACES: FR-CAT-5 | FR-UI-4
/// Take everything the grid is currently showing — the whole library, or
/// the whole of whatever it is scoped and filtered to. Answered from the
/// catalog, not from the loaded window, for the reason `cell-pressed`'s
/// shift argument is: what is on screen is a fraction of what is meant.
callback select-all();
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether the next cell tap should take everything from the last cell
/// tapped to it.
///
/// **Why this exists.** Touch has had a range gesture for as long as
/// selection mode has — double-tap the far end — and it was a gesture only
/// its author could find. It is invisible, it is unreliable on a grid that
/// scrolls under the second tap, and it extends from the anchor *before*
/// the two taps moved it, a rule subtle enough to need two paragraphs of
/// Rust to explain to itself.
///
/// This is the same operation made visible: a button that arms it, a strip
/// that says what the next tap will do, and a way out. It also does the one
/// thing a drag-to-select sweep could not — the user may scroll as far as
/// they like between the two taps, and the run is resolved by the catalog
/// rather than by what happens to be on screen. The ranges that hurt on a
/// tablet are longer than a screenful, which is exactly where a sweep runs
/// out.
///
/// Local to this file, and one-shot: the next press consumes it. Rust needs
/// no state for it, because it arrives as `cell-pressed`'s shift argument
/// and lands in `apply_press` as the shift-click it already knows how to
/// apply.
// GESTURE: Select a range
// where: Library grid
// touch: While selecting, press "Select to…", then tap the last
// photograph of the run
// pointer: Shift-click the last photograph of the run
// why: This replaced a double tap, which had no visible state and
// could take forty photographs by accident. The run is resolved
// by the catalog rather than by what is on screen, so the grid
// can scroll between the two taps — the ranges that hurt on a
// tablet are longer than a screenful, which is exactly where a
// finger sweep runs out.
property <bool> ranging: false;
// A range armed against nothing has no anchor to extend from, and the strip
// that says it is armed is only drawn while there is a selection — so
// "Done" in the header, which drops the selection from outside that strip,
// would leave the mode armed and invisible, and the next ordinary tap would
// take a run the user never asked for.
changed selected-count => {
if (root.selected-count == 0) {
root.ranging = false;
}
}
/// TRACES: FR-CAT-5
/// Make a new collection, under the given name, holding exactly what is
/// selected. The name arrives from the sheet below rather than being
/// invented by Rust and corrected afterwards — see `naming`.
callback collection-from-selection(string);
/// **What arms the drag, not what it carries.**
///
/// `DragArea` refuses to begin a drag while its `data` is empty, and it
/// asks on every pointer event — including the first one, long before
/// anything is being dragged. A binding that calls a callback is evaluated
/// once and cached, because there is nothing for Slint to invalidate it on,
/// so whatever this answers the first time a finger touches a cell is what
/// that cell's `DragArea` believes for the rest of its life.
///
/// It is therefore *not* the payload the drop reads, whatever it looks
/// like: what travels is `dragging` in `collections_ui`, recorded by
/// `drag-started` and read back by `dropped-on`. See the Rust side for why
/// this has to answer something non-empty unconditionally.
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.
///
/// This, not `drag-payload`, is where what the drag carries is decided: it
/// fires with the selection as it stands at the moment the drag starts, and
/// lets Rust promote an unselected cell into the selection first.
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;
/// Whose photographs the grid is narrowed to — one chip each, so a
/// selection of three can be taken apart one person at a time.
in property <[PersonChip]> filter-people;
/// Everyone the library knows, for the tray. Filled on demand — see the
/// tray itself for why it is not simply pushed alongside the chips.
in property <[PersonChip]> people;
/// Asked for when the tray opens, so a roster is never built for a bar
/// nobody has opened and never goes stale in one that is.
callback people-listed();
/// Add or remove one person from the filter, keeping the rest.
///
/// The gesture the whole tray exists for: `filter-person-cleared` can only
/// take somebody *out*, so before this the only way to narrow to two people
/// at once was to visit the People screen twice.
callback filter-person-toggled(int);
/// Whether the people tray is open.
///
/// Private to the view, like every other disclosure here: nothing in Rust
/// needs to know, and a strip whose open state round-tripped through a
/// callback would flicker on every press.
property <bool> people-tray: false;
/// Whether those people are an intersection rather than a union.
in property <bool> filter-people-all: false;
callback filter-person-cleared(int);
callback filter-people-mode-toggled();
/// TRACES: FR-CULL-8a | FR-CULL-13
/// Only photographs in which the chosen people are not blinking. Shown
/// and meaningful only while `filter-people` holds someone.
in property <bool> filter-eyes-open: false;
callback filter-eyes-open-toggled();
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) -----------------------------------------------
//
// 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.
//
// Only the progress. Asking for the pin, and saying whether the scoped
// collection already has one, belong to the collections panel — the tree
// is what decides which collection is meant. This bar reports on the
// transfer wherever it was started, including from a row this grid is not
// scoped to, which is why it is not gated on the scope.
in property <int> pin-done: 0;
in property <int> pin-total: 0;
/// 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;
callback paste-settings-to-selection();
/// TRACES: FR-DEV-6
callback open-presets();
// 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();
// TRACES: FR-MRG-1
// Merge the selection into a panorama. Two frames at least; the page
// that opens says what it found and asks before anything is written.
callback merge-selection();
// --- 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();
/// Show every collection the selection is filed in, with a way out of each.
/// The button above can only ever name the scoped collection; this is how a
/// photograph leaves one the grid is not currently showing — and the only
/// way to find out which collections it is in at all, since the cell badges
/// a count and nothing else.
callback open-membership();
// --- 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;
// --- naming a new collection (FR-CAT-5, FR-CAT-7) -----------------------
//
// "New collection from selection" used to create the collection under a
// placeholder name and then open the rename field in the sidebar tree.
//
// On a tablet the sidebar is not on screen. It is instantiated all the
// same — `app.slint` collapses it to zero width and `visible: false`
// rather than using an `if`, because an `if` there is a layout loop Slint
// panics on — so the rename field was created, its `init` took focus, and
// Android raised the on-screen keyboard for a box nobody could see. Nothing
// else on the screen is focusable, so the keyboard had nowhere to go: it
// stayed, the name could not be typed, and the collection was already
// written under the name the user did not want.
//
// Asked here instead, before anything is written. A sheet dismissed leaves
// no collection behind, which the create-then-rename order could not
// promise.
/// Whether the naming sheet is up. Local, like `filing` and `keywording`.
property <bool> naming: 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;
// A floor, for the same reason the status line beside it
// has one — and it is named here because the two of them
// compete. An eliding `Text` has a minimum of nothing, so
// once the buttons had claimed their own minimums the
// title was the cheapest thing in the row to give away and
// it went to a bare "…": the window's own name, gone,
// while the byte counts beside it stayed. Whatever else
// this row drops, it may not stop saying what is being
// looked at.
min-width: 90px;
}
// Everything the grid has to say about itself, in one line:
// how many images, what is running over them, and where in the
// library the visible window sits. See `status-line` for why
// the four readouts that used to be here are one.
//
// `elide` + `horizontal-stretch: 1`: 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 this readout keeps its full natural width and is
// what pushes 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.status-line;
overflow: elide;
horizontal-stretch: 1;
// Deliberately without a `min-width`, unlike the title.
//
// This row cannot always hold everything: after the
// sidebar takes its 232, a 1100pt window leaves the header
// about 868, and the buttons alone want most of that
// before a single character is drawn. Something has to
// give, and the order matters — a floor here bought a
// readable status by pushing `Settings` off the right
// edge, reachable only by knowing to flick-scroll, which
// is the exact fault this row's Flickable comment warns
// about.
//
// So this is what gives. It is the most redundant thing in
// the header — the sidebar states the library's count and
// the filter chips state it again — and it grows back the
// moment there is room.
}
// 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 {
scanning: root.scanning;
syncing: root.syncing;
can-import: root.can-import;
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
sync-now => { root.sync-now(); }
rescan => { root.rescan(); }
open-import => { root.open-import(); }
open-settings => { root.open-settings(); }
open-people => { root.open-people(); }
open-gestures => { root.helping = true; }
}
// Compact: one button in place of the row. 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
// library's 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 its 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;
scanning: root.scanning;
syncing: root.syncing;
can-import: root.can-import;
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
sync-now => { root.sync-now(); }
rescan => { root.rescan(); }
open-import => { root.open-import(); }
open-settings => { root.open-settings(); }
open-people => { root.open-people(); }
open-gestures => { root.helping = true; }
}
}
}
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 || root.filter-people.length > 0
|| root.filter-eyes-open: 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;
// First on the bar, ahead of "Show". Arriving here from the
// People screen replaces the whole grid, and a chip explaining
// that has to be the first thing read — a user who does not
// find it is looking at a library that has apparently lost
// most of its photographs.
for p[i] in root.filter-people: FilterChip {
icon: "cross";
label: p.name;
active: true;
y: (parent.height - self.height) / 2;
clicked => { root.filter-person-cleared(p.id); }
}
// Only with two, because with one the modes are the same
// filter and a toggle that changes nothing is a control that
// teaches the user it does nothing.
if root.filter-people.length > 1: FilterChip {
label: root.filter-people-all ? "all of them" : "any of them";
active: root.filter-people-all;
y: (parent.height - self.height) / 2;
clicked => { root.filter-people-mode-toggled(); }
}
// Beside the people it applies to, and only with some chosen:
// "Anna, eyes open" is the question, and without a name in
// front of it the chip would be a judgement on everyone in
// the frame. A filter, never a verdict: it hides the blinks
// in a burst and rates nothing (FR-CULL-13).
// GESTURE: Take the blinks out of a burst
// where: Library grid
// touch: Narrow to a person, then tap "Eyes open" beside
// their name on the filter bar
// pointer: Narrow to a person, then click "Eyes open" beside
// their name on the filter bar
// why: Face indexing reads each face's eyes. The chip
// drops frames where the chosen people are caught
// blinking, and leaves sunglasses and eyes it could
// not read alone.
if root.filter-people.length > 0: FilterChip {
icon: "eye";
label: "Eyes open";
active: root.filter-eyes-open;
y: (parent.height - self.height) / 2;
clicked => { root.filter-eyes-open-toggled(); }
}
// The way in to the people tray, and the reason it exists.
//
// Narrowing to *two* people at once was already possible and
// effectively unreachable: the only control that could add a
// second one lived on the People screen, behind selecting them
// there, and it only appeared once the grid was already
// narrowed to somebody. So a user who wanted "photographs with
// both of them" had to guess a two-screen round trip. A filter
// belongs on the filter bar; this chip is the whole feature's
// front door and the tray below is where both terms and the
// any/all choice actually are.
// GESTURE: Find photographs with two people in them
// where: Library grid
// touch: Open the People chip on the filter bar, tap each
// name, then switch the chip beside them to "all of
// them"
// pointer: Open the People chip on the filter bar, click each
// name, then switch the chip beside them to "all of
// them"
// why: "Any of them" is a union and "all of them" is an
// intersection. The tray is where both terms and the
// choice between them live, because a filter belongs
// on the filter bar.
FilterChip {
icon: root.people-tray ? "chevron-down" : "chevron-right";
label: "People";
// The number narrowed to, not the size of the roster: it
// says what the filter is doing, which is what every other
// count on this bar says.
count: root.filter-people.length > 0
? root.filter-people.length : -1;
active: root.people-tray;
y: (parent.height - self.height) / 2;
clicked => {
root.people-tray = !root.people-tray;
// Asked for on open rather than kept in step, so the
// roster is current — indexing and regrouping change
// who exists, and a list cached at startup would be
// stale for exactly the user who has just been naming
// people.
if (root.people-tray) { root.people-listed(); }
}
}
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
|| root.filter-eyes-open)
? "filtered" : "";
emphasised: true;
vertical-alignment: center;
}
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// --- the people tray ----------------------------------------------
//
// A second strip under the filter bar rather than a popup, for the
// reason the develop column's film picker gives: this view already
// scrolls as one, so an inline strip is taller content and not a second
// overlay with its own dismiss gesture to lose a drag to.
//
// Horizontally scrolling, exactly like the bar above it and for the
// same hard reason — **a layout cannot be narrower than its children's
// minimums**, and a library with forty people would otherwise report a
// minimum width of forty chips and inflate the whole view. See the bar
// above for the full account of that fault.
if root.people-tray: Rectangle {
height: 38px;
background: Theme.surface;
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, people-row.preferred-width);
people-row := HorizontalLayout {
width: parent.viewport-width;
height: parent.viewport-height;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: 4px;
alignment: start;
Caption {
// Says what a *pair* of chips will mean before either
// is pressed, which is the thing the old design never
// said anywhere.
text: root.filter-people-all
? "In every one:" : "In the picture:";
vertical-alignment: center;
}
// The roster. Ordered most-photographed-first with the
// named ahead of the rest, so the people a user actually
// intersects are the ones under the thumb without
// scrolling.
for p[i] in root.people: FilterChip {
icon: p.picked ? "check" : "";
label: p.name;
count: p.faces;
active: p.picked;
y: (parent.height - self.height) / 2;
clicked => { root.filter-person-toggled(p.id); }
}
// Not an error and not empty chrome: face indexing is an
// opt-in overnight pass, so "nobody yet" is the ordinary
// state of a library nobody has run it on, and it should
// say where the pass lives.
if root.people.length == 0: Caption {
text: "Nobody indexed yet — find faces on the People screen.";
vertical-alignment: center;
}
}
}
}
// --- 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.
//
// And "still opening" is a third, which is the state a launch is in
// for as long as it takes to read and check the catalog on this
// device. It used to be spent before the window had painted at all,
// where it read as a frozen application — on Android, as an ANR.
// Now it is a worker, so it needs a sentence: a grid saying
// "Scanning…" while nothing is on the network is the same kind of
// lie the two answers below were separated to avoid.
// An empty trash is the ordinary state of a trash, and telling
// someone who opened it to check their folder and formats sends
// them off to fix a library that is fine.
if root.total == 0: EmptyState {
headline: root.opening
? "Opening the library…"
: (root.scanning ? "Scanning…"
: (root.viewing-trash ? "The trash is empty" : "No images found"));
detail: (root.opening || root.scanning)
? root.scan-status
: (root.viewing-trash
? "Photographs you delete wait here until the trash is emptied."
: "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 -----------------------------------------------------
//
// **Scrolls until a photograph has been picked up, and not after.**
//
// `DragArea` and `Flickable` do arbitrate, but not evenly: the
// Flickable claims any press that travels more than eight pixels
// along its own axis within half a second of landing, and it holds
// that claim until the finger lifts. That is right for the ordinary
// case — a finger that moves is almost always scrolling — and it is
// why dragging the background still flicks the grid.
//
// It is wrong once the user has said otherwise. `held-row` is that
// saying: a press that has stayed put long enough to be a pick-up,
// marked on the cell so the user can see it. From there this stops
// being interactive and the drag has nothing left to lose to.
//
// Today the hold outlasts the Flickable's window anyway, so this
// mostly makes an accident into a guarantee — the arbitration stops
// depending on two constants in different crates staying in the
// order they happen to be in. The wheel is unaffected: `interactive`
// does not gate it.
if root.total > 0: grid-scroll := Flickable {
interactive: root.held-row < 0;
// 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.
// GESTURE: Resize the thumbnails
// where: Library grid
// touch: Pinch the grid with two fingers
// pointer: Ctrl and the scroll wheel
// why: There is no wheel on a tablet, so without the
// pinch the cell size could only be changed by a
// control a finger cannot reach.
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;
}
// Nothing to reveal *into* before layout has run, and this
// is now called from `init` as well as on a cursor move —
// where a height of zero would make every row look off
// screen and scroll the grid to the cursor when `seek` had
// just put it somewhere deliberate.
if (self.height <= 0px) {
return;
}
self.revealed = true;
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);
}
}
/// Whether a reveal has run against real geometry yet.
///
/// The safety net for the deferral above, and deliberately a
/// latch rather than a plain `changed height` handler: the first
/// height a rebuilt grid is given must bring the cursor into
/// view, and every height *after* that is a window being
/// resized, where dragging a corner should not keep hauling the
/// viewport back to a cursor the user is not looking at.
property <bool> revealed: false;
changed height => {
if (!self.revealed) {
self.reveal();
}
}
// Also on creation, which is what returning from the develop
// view needs. The grid is gated on an `if`, so it 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.
//
// `reveal()` after `seek()`, and the order is the point: seek
// puts the viewport where the grid was left, reveal then moves
// it as little as will bring the cursor's row into view. Coming
// back from develop the cursor is on the photograph that was
// open, so a frame inside the remembered screenful moves
// nothing at all and one outside it is scrolled just far enough
// to be seen. `cursor-row` is *initialised* here too, so its own
// `changed` handler never fires and this is the only thing that
// can do it.
init => { self.seek(); self.reveal(); }
// 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.
// Plus room for the selection bar, which floats over the
// foot of the grid rather than sitting above it. Extending the
// viewport rather than shrinking the Flickable is what keeps
// the change invisible: every cell stays exactly where it was
// and there is simply further to scroll, so the last row can be
// brought clear of the bar instead of being trapped under it.
// Same condition as the bar itself, or a running export with
// the selection since cleared would leave the last row of
// thumbnails trapped under a bar the grid did not know was
// there.
property <length> selection-inset:
(root.selected-count > 0 || root.exporting) ? 40px : 0px;
viewport-height: root.total-rows * (root.cell-size + Theme.gap)
+ Theme.gap + self.selection-inset;
// 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 != "";
}
// GESTURE: File photographs in a collection
// where: Library grid
// touch: Drag a photograph — or a whole selection — onto a
// collection in the sidebar. Starting a drag stops
// the press becoming a hold, so it cannot leave you
// in selection mode.
// pointer: Drag a photograph — or a whole selection — onto a
// collection in the sidebar
// why: The selection is what the drag carries, which is
// why selecting several is worth the mode: forty
// photographs file in one gesture.
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;
// Not the payload — the arming. See `drag-payload`.
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(); }
// Where a reorder lands. Behind the cell's content and
// before it in the file, for the reason `TreeRow`'s drop
// area gives: a `DropArea` only takes part in a drag, so it
// does not block the presses the cell's own TouchArea
// needs — but drawn first it cannot paint over the
// thumbnail either.
//
// Present on every cell rather than wrapped in an `if`, so
// the marker below can name it. `can-drop` is where the
// refusal lives, which also means the cursor says no while
// the user can still aim somewhere else.
reorder-drop := DropArea {
width: 100%;
height: 100%;
/// Whether the run would land after this photograph
/// rather than before it. Tracked during the hover so
/// the marker can move to the edge the drop will
/// actually use.
property <bool> after: false;
can-drop(ev) => {
if (!root.reorderable) {
return DragAction.none;
}
self.after = ev.position.x > self.width / 2;
return DragAction.copy;
}
dropped(ev) => {
root.reorder-to(i, ev.position.x > self.width / 2);
return DragAction.copy;
}
}
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: Theme.surface;
border-radius: Theme.radius;
// Hover only. **Selection is the inset ring below**, and
// nothing out here changes when a cell is selected.
//
// It used to be a 2px border and a lifted fill, and a
// border drawn on the outside of a 6px-padded cell eats
// into the thumbnail: selecting appeared to nudge the
// photograph, which is exactly the wrong feedback for a
// gesture whose whole job is to say "this one". One
// treatment, drawn inside, in one place.
//
// **Not drawn at all once there has been a finger.** A
// hand has no hover to give, so on a tablet this ring can
// only ever be wrong — and it is wrong in the worst way,
// because it is the selection ring's own colour a pixel
// thinner. `has-hover` is not reliably cleared on touch:
// a release normally brings an `Exit` with it, but the one
// that ends a pinch does not, so every pinch to resize the
// thumbnails left a ring around whichever cell a finger
// happened to have started on. The grid then showed boxes
// around photographs that were not selected, with no way
// to tell them from ones that were. See `touched`.
border-width: cell-touch.has-hover && !root.touched ? 1px : 0px;
border-color: Theme.selected-ring;
clip: true;
VerticalLayout {
padding: 6px;
spacing: 4px;
Rectangle {
vertical-stretch: 1;
background: Theme.ground;
Image {
width: 100%;
height: 100%;
source: cell.thumbnail;
image-fit: contain;
visible: cell.has-thumb;
// Faded while lifted, so the grid reads as the place
// the photograph came *from* and the cursor as where
// it is now. `colorize` would flatten it to one
// tint, which loses the picture; dropping opacity
// toward the ground keeps it recognisable as a ghost
// of itself.
// A rejected frame is held back rather than
// hidden: the cull is reversible, and a photo
// that vanished on one keypress would make the
// gesture frightening to use. Dimming is the
// cue that reads at grid scale — the cross on
// the mark confirms it up close.
opacity: cell.lifted ? 0.25
: (cell.flag == 2 ? 0.4 : 1.0);
animate opacity { duration: 120ms; }
}
if !cell.has-thumb: Caption {
text: cell.unavailable ? "no preview" : "…";
horizontal-alignment: center;
}
// "Already filed, in this many collections." Without it
// there is no way to tell a filed photograph from an
// unfiled one, and the user re-files what is already
// in place.
if cell.collection-count > 0: Rectangle {
x: parent.width - self.width - 4px;
y: 4px;
width: 16px;
height: 16px;
border-radius: 8px;
background: Theme.selected;
opacity: 0.9;
Text {
text: cell.collection-count;
color: Theme.ink;
font-size: 9px;
font-weight: 700;
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// Pick or reject, top left — the opposite corner
// from the collection badge so the two never
// collide on a cell that carries both.
FlagMark {
x: 4px;
y: 4px;
flag: cell.flag;
}
}
Label {
text: cell.name;
emphasised: cell.selected;
overflow: elide;
}
}
// **Selection, and the only mark of it.**
//
// This ring used to mean something narrower — the end a
// shift-click measures from — and it was the clearest thing
// on the cell, so it read as the selection to everyone who
// had not written it. It also outlived what it described: an
// anchor survives a deselection, so a thin box sat around
// the last photograph touched with nothing selected at all,
// and there was no way to tell it apart from a cell that had
// stayed behind.
//
// So the ring is now what it already looked like. The anchor
// has no mark of its own, which costs nothing it was earning:
// the range gesture announces itself in the bar — "Tap the
// last photograph" — and does not need the user to find
// where it will measure from.
//
// Drawn *inside* the cell and 4px clear of the edge, so it
// never touches the thumbnail and never changes a single
// dimension. Selecting adds ink and moves nothing.
Rectangle {
visible: cell.selected;
x: 4px;
y: 4px;
width: parent.width - 8px;
height: parent.height - 8px;
background: transparent;
// Two, not one: this is now the whole of the cue, and it
// has to survive being read across forty cells at arm's
// length against a thumbnail of any brightness.
border-width: 2px;
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.
// GESTURE: Open a photograph
// where: Library grid
// touch: Tap it — a single tap, any length
// pointer: Click it
// why: A tap opens; a tap that *moved* does not.
// Travel is what separates a deliberate tap
// from a hand brushing past, and it is the
// only thing that does: the two are the same
// length. An earlier version required the
// finger to dwell 120 ms instead, and that
// rejected ordinary taps — a real tap is
// often quicker than a brush.
property <int> down-finger: -1;
property <bool> click-pending: false;
// Where the press landed, so the release can tell how
// far the finger travelled.
//
// The Flickable already cancels a press that becomes a
// scroll, but only once it has claimed the gesture,
// which takes more travel than a graze has. This
// catches the rest: a contact that slid across the cell
// and lifted was reaching for something else.
property <length> press-x: 0;
property <length> press-y: 0;
/// How far a finger may slide and still be a tap.
///
/// A thumb's contact patch is wider than this, so no
/// stationary tap approaches it; a hand moving across
/// the glass passes it within one frame. Below about
/// this the number stops describing intent and starts
/// describing how steady the user's hand is.
property <length> tap-slop: 12px;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.down-finger = ev.touch-finger-id;
self.click-pending = false;
self.press-x = self.mouse-x;
self.press-y = self.mouse-y;
// 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) {
// An armed range reaches Rust as shift,
// which is what it is: `apply_press` reads
// ctrl+shift as "add the run from the
// anchor to here", and in selection mode
// ctrl is already set. Sent this way rather
// than as a third selection policy, so the
// rules stay in one place.
root.cell-pressed(
i,
ev.modifiers.control || root.select-mode,
ev.modifiers.shift || root.ranging,
);
// One shot. The range was between two taps
// and the second has landed; left armed, it
// would turn every tap after it into
// another run.
root.ranging = false;
}
}
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
&& abs(self.mouse-x - self.press-x) < self.tap-slop
&& abs(self.mouse-y - self.press-y) < self.tap-slop) {
root.cell-clicked(i);
}
self.click-pending = false;
// Down again, whether or not it was ever up.
root.held-row = -1;
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;
// Including the cancel a starting drag sends:
// by then the `DragArea` has the gesture and
// `lifted` is the mark that matters, so there
// is no scroll left to lose and nothing to
// keep the ring open for.
root.held-row = -1;
root.cell-press-ended();
}
}
clicked => { self.click-pending = true; }
// **No `double-clicked` here, deliberately.** A double
// tap used to take a range in selection mode. It was
// the only gesture touch had for shift-click, and
// "Select to…" replaced it with a control that says
// what it is about to do. Leaving both meant two quick
// taps on one cell — a thing a hand does by accident —
// silently selecting a run of forty photographs, with
// no visible state to explain where they came from.
//
// Now two taps are two toggles and land back where they
// started, which is the only thing a user can predict
// from what is on screen.
// GESTURE: Rate a photograph without opening it
// where: Library grid
// touch: Tap a star on the cell
// pointer: Hover the cell, then click a star
// keys: 0 to 5 on the selection
// why: A star has to take the press without it
// also reaching the cell, or every rating
// throws the user into develop.
//
// --- 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 burst mark (FR-CULL-5): how many frames this
// moment holds,
// and the way in and out of them.
//
// A child of `cell-touch` for exactly the reason the
// stars above are: a click here must not also reach
// `cell-clicked` and throw the user into develop, and
// children are hit-tested before the element they sit
// in. Unlike the stars it is never hidden — a collapsed
// burst is standing in for frames that are not on
// screen, and there has to be something visible saying
// so whether or not a pointer is anywhere near.
//
// Bottom left, clear of the centred star strip and of
// both top corners, which the flag and the collection
// badge already have.
if cell.burst-count > 1: Rectangle {
x: 6px;
y: parent.height - self.height - 26px;
width: 30px;
height: 18px;
// The pile behind the top card, drawn only while the
// group is folded up. It is the whole of the "there
// is more than one of these" cue; once the burst is
// open the frames themselves say it.
Rectangle {
x: 3px;
y: -3px;
width: parent.width - 3px;
height: parent.height;
visible: !cell.burst-expanded;
background: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.rule;
}
Rectangle {
width: 100%;
height: 100%;
background: cell.burst-expanded ? Theme.selected
: Theme.surface;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: cell.burst-expanded ? Theme.selected-ring
: Theme.rule;
Text {
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
// No "of": the number is the size of the
// group, and a cell this small cannot
// afford a word to say so.
text: cell.burst-count;
color: Theme.ink;
font-size: 10px;
font-weight: 700;
}
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.burst-toggled(i); }
}
}
// GESTURE: Choose the frame a folded burst shows
// where: Library grid
// touch: Open the burst, then tap the ring on the
// frame you want
// pointer: Open the burst, then click the ring on the
// frame you want
// why: A folded burst draws its earliest frame,
// which is a fact about the clock and not a
// judgement about the photograph — nothing
// in this application ranks a frame
// (FR-CULL-5). But the point of a burst is
// that one of the twelve is better than the
// other eleven, and the photographer is the
// only one who knows which. So the choice is
// offered on the frames themselves, while
// they are open and side by side, which is
// the one moment the alternatives are on
// screen to be compared.
//
// Drawn on every frame of an open group and on none of
// a folded one. Folded, the single cell showing *is*
// the representative, so the mark would state the
// obvious and the frames it offers to choose between
// would not be on screen to choose from.
//
// Bottom right, opposite the count in the other corner:
// "this many frames" at one end and "this is the one"
// at the other, clear of the flag and the collection
// badge in the top two. A slip between it and ★5 sets a
// rating — the harmless direction for an ambiguous
// press, which is why it is on this side of the cell
// and not beside the trash target.
//
// A child of `cell-touch` for the reason the stars are:
// a press here must be spent on the choice and must not
// also reach `cell-clicked` and open the photograph.
if cell.burst-expanded: Rectangle {
x: parent.width - self.width - 6px;
y: parent.height - self.height - 26px;
width: 18px;
height: 18px;
border-radius: self.height / 2;
background: Theme.surface;
border-width: 1px;
border-color: cell.burst-representative
? Theme.selected-ring : Theme.rule;
// Ticked or empty, and that is the whole of the
// state: which frame stands for the moment is said
// by the presence of a mark rather than by a colour
// (NFR-A11Y-3), so it survives being read at grid
// scale and by an eye that does not separate the
// two hues.
if cell.burst-representative: Icon {
name: "check";
ink: Theme.active;
size: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
// Live on the chosen frame too, rather than inert
// there. A disabled TouchArea lets the press fall
// through to the cell behind it, so tapping the one
// mark that is already ticked would open the
// photograph — a control that does something
// unrelated when pressed twice. Pressing it instead
// says "yes, this one", which is a thing the user
// may mean: the default is the earliest frame, and
// recording the choice keeps it through a regroup
// that finds an earlier one.
TouchArea {
// Grown past the drawn ring to FR-UI-3's
// minimum and centred on it, the way the stars
// beside it are.
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: pointer;
clicked => { root.burst-representative-chosen(i); }
}
}
}
}
// Where the run would land. A bar in the gutter beside the
// cell it would sit next to, on whichever side the drop
// will actually use — the trailing edge is what makes the
// last place in a collection reachable, and a marker that
// did not move with it would be pointing at the wrong gap
// half the time.
//
// Last child of the `DragArea`, so it draws over the
// thumbnail rather than under it. In the gutter rather than
// on the cell, because a bar drawn *on* the first cell of a
// row reads as belonging to that cell instead of to the
// space before it.
if reorder-drop.has-drag: Rectangle {
x: reorder-drop.after
? parent.width + Theme.gap / 2 - 1.5px
: -Theme.gap / 2 - 1.5px;
y: 0;
width: 3px;
height: parent.height;
background: Theme.active;
border-radius: 1.5px;
}
}
// **The photograph is in your hand now.**
//
// A ring that opens outward around the cell a press has held
// long enough to pick up (see `held-row`), so the wait the
// gesture needs has something to end in. Without it the user
// is holding a finger on glass with no way to know whether
// anything has happened — which is what made dragging feel
// like a coin toss.
//
// **Drawn after the cells, not on one.** Cells are one `for`,
// and z-order inside a `for` is the loop order — a cell that
// grew past its bounds would stand over its left and top
// neighbours and be cut off by its right and bottom ones,
// which reads as a rendering fault rather than as a lift. One
// element after the loop is above every cell by construction,
// and there is only ever one photograph in the hand.
//
// `visible` rather than `if`, so it has somewhere to animate
// *from*: an `if` builds the ring at its final size and it
// would appear rather than open.
//
// `Theme.active` and 3px, deliberately unlike the selection
// ring inside the cell — two marks that meant different things
// in one colour is the mistake the hover ring made.
Rectangle {
property <length> pitch: root.cell-size + Theme.gap;
property <length> reach: root.held-row >= 0 ? 5px : 0px;
visible: root.held-row >= 0;
x: Theme.gap
+ mod(root.held-row + root.offset, root.columns) * self.pitch
- self.reach;
y: Theme.gap
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
- self.reach;
width: root.cell-size + 2 * self.reach;
height: root.cell-size + 2 * self.reach;
animate x, y, width, height { duration: 120ms; easing: ease-out; }
background: transparent;
border-width: 3px;
border-color: Theme.active;
border-radius: Theme.radius;
}
}
}
}
}
// --- 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;
}
}
}
// Points at the button below rather than at the sidebar's
// `+`. On a tablet the sidebar is instantiated but not
// drawn, so the old text sent the user to look for a
// control that was not on the screen — and this sheet is
// now the one place that can make a collection without
// one.
if root.collections.length == 0: Text {
text: "No collections yet — make the first one below.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
width: parent.width;
}
}
Rectangle { height: 1px; background: Theme.rule; }
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Cancel";
horizontal-stretch: 1;
clicked => { root.filing = false; }
}
// 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.
//
// Here rather than on the bar because it is the answer to
// a question this sheet has just asked and failed to
// answer: the list above is every collection there is, and
// this is what you press when none of them is the one.
//
// The naming sheet replaces this one rather than stacking
// over it — two scrims deep is a place where dismissing
// once looks like it did nothing.
Button {
text: "New collection…";
primary: true;
horizontal-stretch: 1;
clicked => {
root.filing = false;
root.naming = true;
}
}
}
}
}
}
// --- 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";
// As the naming sheet below does, and for the same
// reason: the field is the only thing to do here. Without
// it the keys went to the grid behind the scrim, and the
// Return meant for the keyword opened a photograph.
init => { self.take-focus(); }
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; }
}
}
}
}
// --- 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.
//
// **Every selection verb, in one place.** For a while this bar held the
// ones that *shape* a selection — clear it, extend it, select the lot —
// while the ones that *act* on it stayed in the header, forty-odd pixels
// from the top of a window whose bottom edge is where the count is. So
// "what can I do with these twelve?" was answered in two places at
// opposite ends of the screen, and the header half moved sideways every
// time the answer changed. Both halves are here now: shaping first, then
// acting, separated by a gap rather than by a journey.
//
// **Over the grid, not above it.** As a row in the vertical flow it
// appeared the instant the first photograph was selected, and every
// cell in the grid moved 40px — so the act of selecting shifted the
// thing being selected out from under the finger, and a second tap
// aimed at the neighbouring photograph landed on the row below it.
//
// A floating bar cannot do that: nothing above it is re-laid out, so
// selecting changes what is drawn and never where. The grid's viewport
// grows by the same 40px while it is there (see `selection-inset`), so
// the last row can still be scrolled clear of it.
//
// Shown for a running export whatever the selection has since become. The
// batch is what `Cancel export` refers to by then, and a run of three
// hundred that could only be stopped by not touching the selection would
// be a trap.
if root.selected-count > 0 || root.exporting: Rectangle {
y: parent.height - self.height;
height: 40px;
background: Theme.surface;
// Swallows presses that land on the bar rather than letting them
// through to the cell beneath. A bar floating over the grid is a bar a
// thumb can reach for and miss into.
//
// Before the Flickable below, so the flick that scrolls this row is
// hit-tested first and this only catches what misses it.
TouchArea { }
// Reads as a bar rather than as a strip of grid that happens to be
// grey: the cells behind it run right up to this edge.
Rectangle {
height: 1px;
background: Theme.rule;
}
// Scrolls sideways, for the reason the header and the chip row both
// give at length: **a layout cannot be narrower than its children's
// minimums**, so a row of ten buttons handed 768 logical pixels does
// not shrink, it overruns — and the buttons past the edge are simply
// gone. That was survivable while this bar held four controls and is
// not now that it holds every verb.
//
// `min-width`, not `preferred-width`, exactly as the header row uses:
// the count beside the buttons elides, so the row genuinely has a
// smaller footprint than its natural one and a window with room for
// the shrunk row should get it shrunk rather than scrolled.
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, selection-row.min-width);
selection-row := HorizontalLayout {
width: parent.viewport-width;
height: parent.viewport-height;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
// No `alignment: start`, which this row used to carry.
//
// Slint only distributes space to `horizontal-stretch` children
// under the default `stretch` alignment; under `start` every child
// gets its preferred width and stretch is silently ignored. With
// four controls that was invisible. With every selection verb on
// the bar it is the difference between a count that gives way and
// a row that can only scroll, so the count keeps its stretch and
// this keeps the default.
// While a range is armed the strip stops reporting and starts
// instructing. The count is still true, but it is not what the
// user needs to read: they have pressed something that changes
// what the *next* tap means, and a mode with no visible state
// is the double-tap gesture this replaces.
if root.ranging: Value {
text: "Tap the last photograph";
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
// Stretch is what actually lets a `Text` shrink under
// pressure in Slint, not `elide` alone — see the note on the
// header row's Flickable. Without it this readout keeps its
// full natural width and is what pushes the buttons beside
// it past the right-hand edge.
horizontal-stretch: 1;
}
if root.ranging: Rectangle { width: Theme.gap; }
// Armed by mistake, or armed and thought better of. Without
// this the only way out is to tap a cell, which takes a run the
// user did not want and then has to be undone by hand.
if root.ranging: Button {
text: "Cancel";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = false; }
}
// State rather than a label: it is what the buttons beside it
// act on.
//
// A running export with nothing selected says so rather than
// reporting "0 photographs selected", which would be true and
// useless — the bar is on screen at that moment for the batch,
// not for the selection.
if !root.ranging: Value {
text: root.selected-count == 0
? "Exporting…"
: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
// See the `ranging` readout above: stretch, not `elide`, is
// what lets this collapse instead of shoving the buttons off
// the end of the bar.
horizontal-stretch: 1;
}
if !root.ranging: 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.
// GESTURE: Drop the selection but keep selecting
// where: Library grid
// touch: Press Clear in the selection strip
// pointer: Press Clear in the selection strip
// why: Distinct from Done, which leaves the mode
// entirely. Clearing keeps it, so the next
// selection can start straight away.
if !root.ranging && root.selected-count > 0: Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// Everything the grid is showing. Cheap to offer and tedious to
// do by hand: a scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those three" is a far
// more common shape than the taps it took to say it.
// GESTURE: Select everything the grid is showing
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// why: A scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those
// three" is a far more common shape than the taps it
// took to say it.
if !root.ranging && root.selected-count > 0: Button {
text: "Select all";
y: (parent.height - self.height) / 2;
clicked => { root.select-all(); }
}
// The visible half of `ranging` — see its declaration for why
// the double-tap it replaces was not good enough. Deliberately
// an unfinished sentence: the ellipsis is the promise that a
// second tap is coming.
if !root.ranging && root.selected-count > 0: Button {
text: "Select to…";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = true; }
}
// --- and here the bar turns from shaping to acting -------------
//
// A gap rather than a rule: the two halves are the same subject
// and the eye only needs to be told they are different sentences.
if !root.ranging: Rectangle { width: Theme.gap-lg; }
// 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.
//
// The only filing button on the bar. "New collection" used to sit
// beside it, which put the same decision — where do these go? — in
// front of the user twice, before they had seen the list that
// answers it. The sheet asks once and offers "New collection…"
// underneath its list, which is where you look after failing to
// find the one you wanted.
if !root.ranging && root.selected-count > 0: Button {
text: "Add to collection";
y: (parent.height - self.height) / 2;
clicked => {
// Reset every time it opens: see `filing-moves`.
root.filing-moves = false;
root.filing = true;
}
}
// TRACES: FR-CAT-5 | FR-CAT-6
// Keyword the selection. Beside the two filing buttons because
// they are the same thought — these photographs are *of*
// something, and they belong *with* something.
if !root.ranging && root.selected-count > 0: Button {
text: "Keywords";
y: (parent.height - self.height) / 2;
clicked => {
// 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;
}
}
// TRACES: FR-DEV-6
// The saved settings, beside the copied ones.
//
// Gated on the selection alone, unlike the paste after it: that
// button needs a clipboard *this session*, where the preset list
// is whatever the photographer saved last month. Requiring an
// armed clipboard here would hide the saved presets behind an
// unrelated action — which is the shape of bug that makes a
// feature only its author knows about (FR-UI-4).
if !root.ranging && root.selected-count > 0: Button {
text: "Presets";
y: (parent.height - self.height) / 2;
clicked => { root.open-presets(); }
}
// TRACES: FR-MRG-1
// A panorama is a selection of at least two; one frame has
// nothing to merge with, so the button waits for a second.
if !root.ranging && root.selected-count > 1: Button {
text: "Merge to panorama";
y: (parent.height - self.height) / 2;
clicked => { root.merge-selection(); }
}
// 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 bar.
//
// 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.ranging && root.selected-count > 0 && root.settings-armed: Button {
text: "Paste to " + root.selected-count;
y: (parent.height - self.height) / 2;
clicked => { root.paste-settings-to-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.ranging && root.selected-count > 0 && root.scope-label != "": Button {
text: "Remove from collection";
y: (parent.height - self.height) / 2;
clicked => { root.remove-from-collection(); }
}
// TRACES: FR-CAT-7 | FR-UI-4
// Where this selection is filed, and the way out of any of it.
//
// Unscoped as well as scoped, unlike the button above: this is the
// one control that can name a collection the grid is not showing,
// and hiding it outside a scope would leave "which collections is
// this photograph in" answerable only by visiting each one.
// GESTURE: Take photographs out of a collection
// where: Library grid
// touch: Select them, then "Collections…" in the selection bar
// pointer: Select them, then "Collections…" in the selection bar
// why: The badge on a cell says a photograph is filed in
// three collections and never which. This is the sheet
// that names them, and the only way out of one the grid
// is not currently scoped to.
if !root.ranging && root.selected-count > 0: Button {
text: "Collections…";
y: (parent.height - self.height) / 2;
clicked => { root.open-membership(); }
}
// 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.
//
// Last on the bar because it is the last thing done to a
// selection, and because it is the one control here that outlives
// the selection: the bar itself stays up for a running batch (see
// the note on the bar) so this is always reachable while there is
// something to cancel.
//
// 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.ranging: Button {
text: root.exporting
? "Cancel export"
: (root.export-to-server
? "Export " + root.selected-count + " to the library"
: "Export " + root.selected-count);
active: root.exporting;
y: (parent.height - self.height) / 2;
clicked => {
if (root.exporting) {
root.cancel-export();
} else {
root.export-selection();
}
}
}
}
}
}
// TRACES: FR-UI-2 | FR-UI-4
// The gesture reference. Last of the sheets, and above them all, because it
// is the one a user opens *because* another one confused them.
if root.helping: GestureSheet {
width: 100%;
height: 100%;
rows: root.gestures;
close => { root.helping = false; }
}
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
//
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
// above. The short version is that on a tablet the sidebar is instantiated
// but not drawn, so the rename field could take the keyboard without ever
// being visible.
//
// The same card, scrim and dismissal as the two sheets above it, for the
// same reason they share one: a user who has filed a selection knows how
// this works.
if root.naming: 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.naming = false; }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: min(name-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
// A third of the way down, not centred. The field below takes the
// keyboard as the sheet appears, and on a tablet the keyboard is
// the bottom half of the window — a card centred in the window is a
// card centred behind it.
y: max(Theme.gap-lg, (parent.height - self.height) / 3);
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 { }
name-sheet := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: root.selected-count == 1
? "New collection holding 1 photograph"
: "New collection holding " + root.selected-count
+ " photographs";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
name-field := Field {
placeholder: "Name this collection";
// The card asks one question, so the field answers the
// keyboard for it. This is also what raises the on-screen
// keyboard on Android — over a field that is on screen,
// which is the whole difference from the old path.
init => { self.take-focus(); }
// Return commits, as it does in every other field here.
// Guarded rather than trusting `enabled` on the button
// beside it: this is a second way in and it must refuse an
// empty name on its own.
accepted(text) => {
if (text != "") {
root.collection-from-selection(text);
root.naming = false;
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { root.naming = false; }
}
Button {
text: "Create";
primary: true;
// A collection called "New collection" is the state
// this sheet exists to prevent, so Create waits for a
// name rather than inventing one.
enabled: name-field.text != "";
clicked => {
root.collection-from-selection(name-field.text);
root.naming = false;
}
}
}
}
}
}
}