NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and outstanding.md said "no compliance work found". Both were wrong. Five places already implement it, and four of them name the requirement in a comment explaining the design; what none of them had was a TRACES line. - `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%` distinct from `0%`, so the text cannot say "none" while the marker beside it is lit. - `histogram.slint`'s ClipReadout is the other half: a marker that appears and disappears rather than changing tint, and the figure next to it. Either alone reads. - `library.slint`'s star strip is a solid star against an outline, differing in shape and luminance, over an achromatic palette. - `library.slint`'s FlagMark is a tick against a cross, and a reject also dims its whole cell. - `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing between hues, presented only as hues, is unusable by exactly the person most likely to need it. The tag is honest about being wider than the evidence, and outstanding.md now records both gaps. Only the clipping clause has a test that would fail if the behaviour were removed; the three Slint components are argued rather than asserted. And the requirement's first named example — catalog colour labels — has no interface at all: `label` is a nullable column nothing writes or shows. That clause is untestable rather than satisfied, and closes when the label UI is built with a shape from the start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3974 lines
192 KiB
Plaintext
3974 lines
192 KiB
Plaintext
// The library grid: what a scanned library looks like.
|
||
//
|
||
// Cells come from Rust as a windowed model, never the whole catalog — a 17k
|
||
// image library must not become 17k live elements (FR-CAT-4).
|
||
//
|
||
// A cell with no thumbnail yet shows why rather than an empty box. On a remote
|
||
// library a thumbnail is a network round trip, so "nothing there", "not
|
||
// fetched yet", and "no preview in this file" are three different states and
|
||
// must not look identical (FR-NC-6c).
|
||
|
||
import { Theme } from "theme.slint";
|
||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, 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;
|
||
/// True once the user has taken control. Until then the marker rests at the
|
||
/// middle rather than pinning to either end, which would imply a selection
|
||
/// that has not been made.
|
||
in property <bool> anchored: false;
|
||
|
||
/// Scrub to a fraction along the visible span. Rust turns it into an
|
||
/// instant, interpolating within a bucket rather than snapping to its edge.
|
||
callback scrub-to(float);
|
||
callback pan(float);
|
||
callback zoom(int);
|
||
/// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches
|
||
/// them together. Continuous, unlike the wheel's discrete steps.
|
||
callback pinch(float);
|
||
|
||
/// TRACES: FR-CAT-6
|
||
/// The chosen date range, as fractions of the drawn span — the same
|
||
/// coordinates `scrub-to` reports and `current-fraction` is drawn in, so
|
||
/// the band, the marker and a click cannot disagree about where a date
|
||
/// sits. Negative in both when no range is set.
|
||
in property <float> range-from: -1;
|
||
in property <float> range-to: -1;
|
||
/// An end of the range was dragged, and where both ends now are.
|
||
///
|
||
/// Sent on release rather than continuously: each one of these re-runs the
|
||
/// grid's query and reloads its window, and doing that per frame of a drag
|
||
/// is how a filter turns into a stutter. The band tracks the finger
|
||
/// meanwhile — that is what `live-from` and `live-to` are for.
|
||
callback range-changed(float, float);
|
||
|
||
width: 96px;
|
||
background: Theme.surface;
|
||
|
||
property <int> hovered: -1;
|
||
property <length> track-top: 22px;
|
||
property <length> track-height: max(1px, self.height - self.track-top - 6px);
|
||
property <length> slot: root.track-height / max(1, root.bars.length);
|
||
|
||
property <bool> has-range: root.range-from >= 0 && root.range-to >= 0;
|
||
/// Which end is under the finger: 0 neither, 1 the early end, 2 the late.
|
||
property <int> grabbed: 0;
|
||
/// The ends as the drag has them, before Rust has been told. Drawing from
|
||
/// these is what lets the band follow the finger without the grid being
|
||
/// re-queried on every frame.
|
||
property <float> live-from;
|
||
property <float> live-to;
|
||
property <float> shown-from: root.grabbed > 0 ? root.live-from : root.range-from;
|
||
property <float> shown-to: root.grabbed > 0 ? root.live-to : root.range-to;
|
||
/// How near a press has to land to take hold of an end, as a fraction of
|
||
/// the track. Sized for a finger rather than a pointer: this control
|
||
/// exists because on a phone the range could only be typed (FR-UI-2).
|
||
property <float> grab: 22px / root.track-height;
|
||
|
||
/// Bucket index under a y coordinate, clamped to the ends so a drag that
|
||
/// leaves the widget still scrubs to the nearest bucket rather than
|
||
/// stopping dead.
|
||
function bucket-at(y: length) -> int {
|
||
return clamp(floor((y - root.track-top) / max(1px, root.slot)),
|
||
0, max(0, root.bars.length - 1));
|
||
}
|
||
|
||
/// Position along the axis as a fraction, 0 at the first bucket's start and
|
||
/// 1 at the last one's end.
|
||
///
|
||
/// **Fractional rather than a bucket index.** Snapping to whole buckets
|
||
/// makes a slow drag feel dead — the pointer moves and nothing happens
|
||
/// until it crosses a boundary, then the grid jumps a whole month. Rust
|
||
/// interpolates an instant from this, so the grid tracks the finger.
|
||
function fraction-at(y: length) -> float {
|
||
return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0);
|
||
}
|
||
|
||
/// Where a fraction sits on the track. The inverse of `fraction-at`, and
|
||
/// it has to stay so: the band is drawn with this and dragged with that.
|
||
function y-at(f: float) -> length {
|
||
return root.track-top + clamp(f, 0.0, 1.0) * root.track-height;
|
||
}
|
||
|
||
/// Which end of the range a press at `y` takes hold of, 0 for neither.
|
||
///
|
||
/// Compared in fractions rather than pixels because the ends are held as
|
||
/// fractions — converting them to a y here and back to a fraction on the
|
||
/// drag would be a second copy of the same arithmetic to keep in step.
|
||
function handle-at(y: length) -> int {
|
||
if (!root.has-range) {
|
||
return 0;
|
||
}
|
||
if (min(abs(root.fraction-at(y) - root.shown-from),
|
||
abs(root.fraction-at(y) - root.shown-to)) > root.grab) {
|
||
return 0;
|
||
}
|
||
// A range narrowed to a day draws one line, not two. Taking the
|
||
// nearer end there would always answer "the early one" and leave the
|
||
// late end impossible to pull back out; which side of the line the
|
||
// press landed on is what the user meant by it.
|
||
if (abs(root.shown-from - root.shown-to) < 0.001) {
|
||
return root.fraction-at(y) > root.shown-from ? 2 : 1;
|
||
}
|
||
return abs(root.fraction-at(y) - root.shown-from)
|
||
< abs(root.fraction-at(y) - root.shown-to) ? 1 : 2;
|
||
}
|
||
|
||
/// Move the held end to `y`.
|
||
///
|
||
/// The ends are allowed to cross. Dragging one past the other is a normal
|
||
/// way to say "no, from *here* instead", and Rust puts the pair back in
|
||
/// order — stopping the finger dead at the other end would make a range
|
||
/// that has been narrowed too far harder to correct than to start again.
|
||
function drag-end(y: length) {
|
||
if (root.grabbed == 1) {
|
||
root.live-from = root.fraction-at(y);
|
||
} else {
|
||
root.live-to = root.fraction-at(y);
|
||
}
|
||
}
|
||
|
||
// Header: the hovered bucket, else the whole span.
|
||
Caption {
|
||
x: 8px;
|
||
y: 4px;
|
||
width: parent.width - 16px;
|
||
text: root.hovered >= 0 && root.hovered < root.bars.length
|
||
? root.bars[root.hovered].label + " · " + root.bars[root.hovered].count
|
||
: root.range-label;
|
||
emphasised: root.hovered >= 0;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The bars. Purely visual — every gesture is handled by the single
|
||
// TouchArea below, which sits above them.
|
||
for bar[i] in root.bars: Rectangle {
|
||
// Bars grow from the *left* edge, so the axis reads like a timeline
|
||
// turned on its side and the labels have room on the right.
|
||
x: 0;
|
||
y: root.track-top + i * root.slot;
|
||
width: max(1px, (parent.width - 34px) * bar.height);
|
||
height: max(1px, root.slot - 1px);
|
||
background: bar.start == root.current-start
|
||
? Theme.active
|
||
: (root.hovered == i ? Theme.ink-dim : Theme.ink-faint);
|
||
}
|
||
|
||
// Year and month labels down the right-hand edge.
|
||
//
|
||
// Drawn only where a bar *starts* a new period, so a month-bucketed view
|
||
// labels each January rather than repeating the year on every bar. The
|
||
// label text itself is chosen in Rust, which knows the granularity; an
|
||
// empty string means "no boundary here".
|
||
for bar[i] in root.bars: Text {
|
||
x: parent.width - 32px;
|
||
y: root.track-top + i * root.slot - 5px;
|
||
width: 30px;
|
||
text: bar.period-label;
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
visible: bar.period-label != "";
|
||
}
|
||
|
||
// Where the grid currently sits. Rests at the midpoint until the user has
|
||
// actually chosen a position.
|
||
Rectangle {
|
||
x: 0;
|
||
width: parent.width;
|
||
height: 1px;
|
||
background: Theme.active;
|
||
opacity: root.anchored ? 1.0 : 0.35;
|
||
y: root.anchored && root.current-fraction >= 0
|
||
? root.y-at(root.current-fraction)
|
||
: root.track-top + root.track-height / 2;
|
||
}
|
||
|
||
// --- the chosen date range ------------------------------------------
|
||
//
|
||
// Drawn *on* the axis, and dragged there. The two typed date fields under
|
||
// the filter chips were the only way to state a range, and on a phone they
|
||
// were barely a way at all: `YYYY-MM-DD` keyed into a 108px field behind a
|
||
// soft keyboard, to name a day that is already drawn on the axis a thumb
|
||
// away. The other route — zoom the axis to a period, then "limit to range"
|
||
// — needs a wheel to zoom and a middle button to pan, and a touch screen
|
||
// has neither.
|
||
//
|
||
// The axis keeps its full extent while a range is on (see
|
||
// `refresh_timeline`, and `Filter::without_date_range`). It has to: the
|
||
// bars outside the band are what the range is widened back *into*, and an
|
||
// axis that redrew itself to the band would move the ground under the very
|
||
// handles doing the narrowing.
|
||
if root.has-range: Rectangle {
|
||
// Outside the range, held back rather than hidden — those bars are
|
||
// still the shape of the library, and still where the band is going
|
||
// next. Starts at the track, so the header line stays legible.
|
||
Rectangle {
|
||
x: 0;
|
||
y: root.track-top;
|
||
width: parent.width;
|
||
height: max(0px, root.y-at(min(root.shown-from, root.shown-to)) - root.track-top);
|
||
background: Theme.surface;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
Rectangle {
|
||
x: 0;
|
||
y: root.y-at(max(root.shown-from, root.shown-to));
|
||
width: parent.width;
|
||
height: max(0px, parent.height - self.y);
|
||
background: Theme.surface;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
// The ends themselves: a line across the axis with a grip at the left,
|
||
// where the bars start. The right-hand strip carries the year and
|
||
// month labels, and a grip there would sit on top of them.
|
||
for edge[i] in [0, 1]: Rectangle {
|
||
x: 0;
|
||
y: root.y-at(i == 0 ? root.shown-from : root.shown-to) - 1px;
|
||
width: parent.width;
|
||
height: 2px;
|
||
background: Theme.active;
|
||
|
||
Rectangle {
|
||
x: 2px;
|
||
y: -5px;
|
||
width: 18px;
|
||
height: 12px;
|
||
border-radius: 3px;
|
||
background: Theme.active;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The single hit area. Everything above is inert.
|
||
// Two-finger pinch, for tablet. There is no wheel there, so without this
|
||
// the axis could only be zoomed by a control a finger cannot reach.
|
||
//
|
||
// `scale` is cumulative from 1.0 for the whole gesture, so the delta since
|
||
// the last update is what maps onto a zoom step — otherwise a slow spread
|
||
// would apply its total repeatedly and shoot straight to full zoom.
|
||
pinch := ScaleRotateGestureHandler {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => { self.last-scale = 1.0; }
|
||
updated => {
|
||
root.pinch(self.scale / max(0.01, self.last-scale));
|
||
self.last-scale = self.scale;
|
||
}
|
||
ended => { self.last-scale = 1.0; }
|
||
cancelled => { self.last-scale = 1.0; }
|
||
}
|
||
|
||
touch := TouchArea {
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: pointer;
|
||
|
||
property <length> press-y;
|
||
property <bool> panning;
|
||
|
||
moved => {
|
||
if (root.grabbed > 0) {
|
||
// The band follows the finger. The grid hears about it on
|
||
// release — see `range-changed`.
|
||
root.drag-end(self.mouse-y);
|
||
} else if (self.panning) {
|
||
// Fractional, so a slow drag moves the view continuously
|
||
// rather than sitting still until it crosses a bucket edge.
|
||
root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height));
|
||
self.press-y = self.mouse-y;
|
||
} else if (self.pressed && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
root.hovered = root.bars.length > 0 ? root.bucket-at(self.mouse-y) : -1;
|
||
}
|
||
|
||
pointer-event(e) => {
|
||
if (e.kind == PointerEventKind.down) {
|
||
self.press-y = self.mouse-y;
|
||
// Middle button pans. Shift is not consulted here: the
|
||
// modifier state belongs to the key handler, not the pointer
|
||
// event, and a middle-drag is the unambiguous gesture.
|
||
self.panning = e.button == PointerEventButton.middle;
|
||
// An end of the range first, if the press landed on one: a
|
||
// press within a finger's width of a handle means the handle,
|
||
// not the scrub it would otherwise have been.
|
||
root.grabbed = self.panning ? 0 : root.handle-at(self.mouse-y);
|
||
if (root.grabbed > 0) {
|
||
root.live-from = root.range-from;
|
||
root.live-to = root.range-to;
|
||
} else if (!self.panning && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
}
|
||
if (e.kind == PointerEventKind.up) {
|
||
self.panning = false;
|
||
if (root.grabbed > 0) {
|
||
root.grabbed = 0;
|
||
root.range-changed(root.live-from, root.live-to);
|
||
}
|
||
}
|
||
// A cancelled gesture — the system taking the touch for a
|
||
// back-swipe, most often — is not a range the user stated. The
|
||
// band goes back to where Rust still has it.
|
||
if (e.kind == PointerEventKind.cancel) {
|
||
self.panning = false;
|
||
root.grabbed = 0;
|
||
}
|
||
}
|
||
|
||
scroll-event(e) => {
|
||
// Wheel zooms rather than scrolls: the sidebar is an axis, not a
|
||
// list, and a scroll gesture over it means "show me more or less
|
||
// time" rather than "move down".
|
||
root.zoom(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
|
||
changed has-hover => {
|
||
if (!self.has-hover) { root.hovered = -1; }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
// The photo roll: the grid's loaded window along the foot of the develop view.
|
||
//
|
||
// # Why this exists
|
||
//
|
||
// Develop opens *one* photograph. `index` and `total` are pinned to "1 of 1"
|
||
// on the way in, because the grid hands over a path and nothing else, so the
|
||
// only way to reach the next frame was to go back to the library, find where
|
||
// you were, and tap again. That is fine once and intolerable through a set of
|
||
// forty — which is precisely the situation the develop view exists for.
|
||
//
|
||
// # The gesture
|
||
//
|
||
// A swipe up from the bottom edge brings it out, a swipe down puts it away:
|
||
// the sheet gesture, already in the hands of anyone who has used a phone.
|
||
// A gesture with no visible counterpart is a feature only its author knows
|
||
// about (FR-UI-4), so a handle is drawn at the edge and is a button in its own
|
||
// right — which is also what gives a pointer, with no swipe to make, a way in.
|
||
//
|
||
// The handler wraps the strip rather than sitting over or under it. That is
|
||
// what `SwipeGestureHandler` is built for: it delays a press the way a
|
||
// Flickable does, forwards it to the children if no swipe develops, and claims
|
||
// it once one does. A tap therefore reaches the thumbnail and a drag does not.
|
||
//
|
||
// It covers only the band along the bottom, never the whole canvas — above
|
||
// that band a drag belongs to the photograph, for panning and for the crop.
|
||
export component PhotoRoll inherits Rectangle {
|
||
in property <[LibraryCell]> cells;
|
||
/// Which row of the loaded window is open, so it can be marked. `-1` when
|
||
/// the open photograph is not in the window at all, which is the honest
|
||
/// answer after a scrub — the roll shows where you are, and sometimes the
|
||
/// answer is "not here".
|
||
in property <int> current: -1;
|
||
in-out property <bool> open: false;
|
||
|
||
/// A thumbnail was chosen. The row within the loaded window, matching what
|
||
/// a cell click reports.
|
||
callback pick(int);
|
||
|
||
property <length> strip-height: 108px;
|
||
/// The band of canvas left grabbable when the roll is away. A thumb's
|
||
/// worth, and no more: it is taken off the bottom of the photograph.
|
||
property <length> reach: 28px;
|
||
property <length> thumb: 92px;
|
||
property <length> pad: 6px;
|
||
|
||
background: transparent;
|
||
|
||
swipe := SwipeGestureHandler {
|
||
width: 100%;
|
||
height: root.strip-height + root.reach;
|
||
y: parent.height - self.height;
|
||
|
||
handle-swipe-up: !root.open;
|
||
handle-swipe-down: root.open;
|
||
|
||
// Direction decides, not distance: the handler has already applied its
|
||
// own threshold by the time this fires, and re-testing the travel here
|
||
// would mean a swipe that qualified as a swipe still did nothing.
|
||
swiped => {
|
||
root.open = self.current-position.y < self.pressed-position.y;
|
||
}
|
||
|
||
// --- the strip ------------------------------------------------
|
||
//
|
||
// Slid out of view rather than removed. An `if` would have it appear
|
||
// fully formed at the bottom of the screen instead of arriving from
|
||
// the edge, and would leave nothing for the animation to act on.
|
||
strip := Rectangle {
|
||
width: 100%;
|
||
height: root.strip-height;
|
||
y: root.open ? root.reach : parent.height;
|
||
animate y { duration: 180ms; easing: ease-out; }
|
||
background: Theme.surface;
|
||
clip: true;
|
||
|
||
Rectangle {
|
||
width: 100%;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
roll := Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width:
|
||
max(self.width,
|
||
root.cells.length * (root.thumb + root.pad) + root.pad);
|
||
|
||
// Bring the open photograph into view — on opening, and when
|
||
// the roll itself moves the selection along. Without it a pick
|
||
// near the end of the window scrolls back to the start on the
|
||
// next reveal, and the mark the strip exists to show is off
|
||
// the edge of it.
|
||
function reveal() {
|
||
if (root.current < 0) {
|
||
return;
|
||
}
|
||
let left = root.pad + root.current * (root.thumb + root.pad);
|
||
let shown = -self.viewport-x;
|
||
let right = max(0px, self.viewport-width - self.width);
|
||
if (left < shown) {
|
||
self.viewport-x = -min(right, left);
|
||
} else if (left + root.thumb > shown + self.width) {
|
||
self.viewport-x =
|
||
-min(right, left + root.thumb - self.width);
|
||
}
|
||
}
|
||
|
||
property <int> mark: root.current;
|
||
changed mark => { self.reveal(); }
|
||
property <bool> shown: root.open;
|
||
changed shown => {
|
||
if (self.shown) { self.reveal(); }
|
||
}
|
||
|
||
for cell[i] in root.cells: Rectangle {
|
||
x: root.pad + i * (root.thumb + root.pad);
|
||
y: root.pad;
|
||
width: root.thumb;
|
||
height: parent.height - 2 * root.pad;
|
||
background: Theme.ground;
|
||
border-radius: Theme.radius;
|
||
// The one that is open, marked the way the grid marks a
|
||
// selection so the two read as the same idea.
|
||
border-width: i == root.current ? 2px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
}
|
||
|
||
TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => { root.pick(i); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the handle -----------------------------------------------
|
||
//
|
||
// Rides on the strip's top edge, so it is in the same place relative to
|
||
// the roll whether it is in or out, and it is the affordance that stops
|
||
// the swipe being folklore.
|
||
Rectangle {
|
||
width: 84px;
|
||
height: root.reach;
|
||
x: (parent.width - self.width) / 2;
|
||
y: strip.y - self.height;
|
||
animate y { duration: 180ms; easing: ease-out; }
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
|
||
Rectangle {
|
||
width: 32px;
|
||
height: 3px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.ink-dim;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => { root.open = !root.open; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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, three status readouts and six buttons. Laid out in one row they all
|
||
// shrink to their minimum and elide — "Change library" arrives as "Change
|
||
// li…" — and the row still overflows its own width. Slint's HorizontalLayout
|
||
// has no wrap and no overflow, so the row has to be told what to drop.
|
||
//
|
||
// Extracted rather than duplicated because the alternative is six buttons
|
||
// written twice with their visibility rules and callbacks copied, and the
|
||
// copy that gets forgotten is the one behind the disclosure nobody opens
|
||
// while testing.
|
||
component HeaderActions inherits HorizontalLayout {
|
||
in property <string> scope-label;
|
||
in property <int> selected-count: 0;
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Whether taps are selecting rather than opening. The non-gesture half of
|
||
/// touch multi-selection: the long press is the quick way in, and this is
|
||
/// the way that can be *found*.
|
||
in property <bool> select-mode: false;
|
||
in property <bool> scanning: false;
|
||
in property <bool> syncing: false;
|
||
in property <bool> scope-pinned: false;
|
||
/// TRACES: FR-DEV-6
|
||
/// Whether settings have been copied, and what pasting them would apply.
|
||
in property <bool> settings-armed: false;
|
||
in property <string> settings-summary;
|
||
/// TRACES: FR-EXP-7
|
||
/// Whether a batch is running, and where its files are going.
|
||
in property <bool> exporting: false;
|
||
in property <bool> export-to-server: false;
|
||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||
/// is sized to its content.
|
||
in property <bool> centred: true;
|
||
in property <length> row-height: 44px;
|
||
|
||
callback export-selection();
|
||
callback cancel-export();
|
||
callback paste-settings-to-selection();
|
||
/// TRACES: FR-DEV-6
|
||
/// Open the named-preset sheet over the selection.
|
||
callback open-presets();
|
||
callback remove-from-collection();
|
||
/// Open the sheet that files the selection in a collection.
|
||
callback add-to-collection();
|
||
/// TRACES: FR-CAT-5
|
||
/// Open the sheet that keywords the selection.
|
||
callback add-keyword();
|
||
callback toggle-select-mode();
|
||
callback change-library();
|
||
callback toggle-pin-scope();
|
||
callback sync-now();
|
||
/// TRACES: FR-CAT-10
|
||
/// Whether this platform can reach a card at all (`dr_plat::imports_supported`).
|
||
///
|
||
/// Hidden rather than disabled, unlike the buttons above that come and go
|
||
/// with the selection: those are unavailable *now* and will be available
|
||
/// in a moment, where this one never will be on this device. A permanently
|
||
/// disabled control teaches the reader that the row lies.
|
||
in property <bool> can-import: false;
|
||
|
||
callback rescan();
|
||
callback open-import();
|
||
callback open-settings();
|
||
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
|
||
// Selection mode, first in the row because it is what the rest of the row
|
||
// acts on: every button after this one appears once something is selected.
|
||
//
|
||
// It exists at all because the desktop's way in — ctrl-click, shift-click —
|
||
// has no touch equivalent, and the drag that files a photograph in a
|
||
// collection needs a *selection* before it can carry more than one. On a
|
||
// tablet the long press does this faster; a gesture with no visible
|
||
// counterpart is a feature only its author knows about (FR-UI-4).
|
||
//
|
||
// "Done" rather than "Selecting": the label should say what pressing it
|
||
// does, and the inverted fill already says which state we are in.
|
||
Button {
|
||
text: root.select-mode ? "Done" : "Select";
|
||
active: root.select-mode;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.toggle-select-mode(); }
|
||
}
|
||
|
||
// TRACES: FR-CAT-7 | FR-UI-4
|
||
// File the selection in a collection without dragging it there. The drag
|
||
// is the faster gesture with a pointer and impossible with one finger on a
|
||
// grid that scrolls, which is the whole reason this button exists.
|
||
if root.selected-count > 0: Button {
|
||
text: "Add to collection";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.add-to-collection(); }
|
||
}
|
||
|
||
// TRACES: FR-CAT-5 | FR-CAT-6
|
||
// Keyword the selection. Beside "Add to collection" because they are the
|
||
// same thought — these photographs are *of* something, and they belong
|
||
// *with* something — and appearing under the same condition, because
|
||
// neither means anything without a selection to act on.
|
||
if root.selected-count > 0: Button {
|
||
text: "Keywords";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.add-keyword(); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-6
|
||
// Batch-apply the copied settings. Shown only with both a selection and a
|
||
// clipboard, because it is meaningless without either — and because a
|
||
// permanently visible button that is usually disabled teaches the user to
|
||
// stop reading this row.
|
||
//
|
||
// The count is in the label rather than in a confirmation: this writes to
|
||
// every selected image, and "Paste to 40" said before the click is worth
|
||
// more than a dialogue asking the same question after it.
|
||
if root.selected-count > 0 && root.settings-armed: Button {
|
||
text: "Paste to " + root.selected-count;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.paste-settings-to-selection(); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-6
|
||
// The saved settings, beside the copied ones.
|
||
//
|
||
// Gated on the selection alone, unlike the paste beside 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.selected-count > 0: Button {
|
||
text: "Presets";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-presets(); }
|
||
}
|
||
|
||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||
// Export the selection, and stop the batch that is running.
|
||
//
|
||
// One button doing both, because they are the same thought a moment apart
|
||
// and a separate cancel would have to appear from somewhere — shifting the
|
||
// row under the pointer at the exact moment the user is reaching for it.
|
||
//
|
||
// It stays while a batch runs whatever the selection has since become: the
|
||
// run is what the button now refers to, and a batch of three hundred that
|
||
// could only be stopped by not changing the selection would be a trap.
|
||
//
|
||
// The count is in the label rather than behind a confirmation, exactly as
|
||
// the paste above puts it there: "Export 40" read before the click is worth
|
||
// more than a dialogue asking the same question after it.
|
||
if root.selected-count > 0 || root.exporting: Button {
|
||
text: root.exporting
|
||
? "Cancel export"
|
||
: (root.export-to-server
|
||
? "Export " + root.selected-count + " to the library"
|
||
: "Export " + root.selected-count);
|
||
active: root.exporting;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => {
|
||
if (root.exporting) {
|
||
root.cancel-export();
|
||
} else {
|
||
root.export-selection();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Removing from a collection is only meaningful while the grid is scoped
|
||
// to one. Offering it unscoped would invite the reading "remove from the
|
||
// library", which nothing here does.
|
||
if root.scope-label != "" && root.selected-count > 0: Button {
|
||
text: "Remove from collection";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.remove-from-collection(); }
|
||
}
|
||
|
||
if !root.scanning: Button {
|
||
text: "Change library";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.change-library(); }
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// Keep this collection offline. Only offered when the grid is scoped to
|
||
// one: "pin the whole library" is a different and much more expensive
|
||
// request, and a button that meant either depending on invisible state
|
||
// would be a trap.
|
||
if root.scope-label != "": Button {
|
||
// No tick on the kept label: `active` already inverts the button,
|
||
// which says the same thing without a symbol inside a string.
|
||
//
|
||
// It opens the same question the sidebar's tray and a held row open,
|
||
// rather than pinning outright — see the note on the callback in
|
||
// `library_ui::wire`.
|
||
text: root.scope-pinned ? "Kept offline" : "Keep offline";
|
||
active: root.scope-pinned;
|
||
enabled: !root.scanning;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.toggle-pin-scope(); }
|
||
}
|
||
|
||
Button {
|
||
// Shares the finished index so a second device inherits it rather
|
||
// than repeating hours of range fetches.
|
||
text: root.syncing ? "Syncing…" : "Sync";
|
||
enabled: !root.syncing && !root.scanning;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.sync-now(); }
|
||
}
|
||
|
||
if !root.scanning: Button {
|
||
text: "Rescan";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.rescan(); }
|
||
}
|
||
|
||
// TRACES: FR-CAT-10
|
||
// Beside Rescan rather than beside Settings: both put photographs into the
|
||
// library, where Settings is about the application. Hidden during a scan
|
||
// for the same reason Rescan is — an import writes files the running scan
|
||
// would then half-see, and the two disagreeing about what is in a folder
|
||
// is a worse outcome than waiting.
|
||
if !root.scanning && root.can-import: Button {
|
||
text: "Import";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-import(); }
|
||
}
|
||
|
||
// 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. The buttons before it come and go
|
||
// with what the grid is showing; settings is always reachable, and a
|
||
// control that moved as its neighbours appeared would be hunted for each
|
||
// time.
|
||
Button {
|
||
text: "Settings";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-settings(); }
|
||
}
|
||
}
|
||
|
||
export component LibraryGrid inherits Rectangle {
|
||
/// TRACES: FR-UI-1
|
||
/// The layout class, from the window width rather than the device.
|
||
///
|
||
/// Compact moves the header's actions behind a disclosure; see
|
||
/// [`HeaderActions`]. Defaults to expanded so a caller that forgets to
|
||
/// pass it gets the desktop layout rather than a hidden toolbar.
|
||
in property <bool> expanded: true;
|
||
/// Whether the compact action row is showing. Local to the grid: it is a
|
||
/// disclosure, not a preference, and it should close itself the moment
|
||
/// the window is wide enough not to need it.
|
||
property <bool> actions-open: false;
|
||
changed expanded => {
|
||
if (root.expanded) { root.actions-open = false; }
|
||
}
|
||
|
||
in property <[LibraryCell]> cells;
|
||
in property <int> total: 0;
|
||
in property <bool> scanning: false;
|
||
in property <string> scan-status: "";
|
||
in property <string> scan-error: "";
|
||
/// Which folder is being shown. Visible at all times: two similarly-named
|
||
/// folders are easy to confuse, and a scan of the wrong one looks
|
||
/// identical to a broken scan.
|
||
in property <string> root-label: "";
|
||
|
||
// --- capture-time scrubber ---
|
||
in property <[TimelineBar]> timeline;
|
||
in property <string> timeline-label: "";
|
||
/// Dates spanned by the cells currently shown.
|
||
in property <string> window-label: "";
|
||
in property <int> offset: 0;
|
||
/// Where the view should be, as an image ordinal. A scrub sets this; the
|
||
/// grid follows it.
|
||
///
|
||
/// Bumped by `scroll-token` rather than watched directly: scrubbing twice
|
||
/// to the same date must still move the view, and an unchanged property
|
||
/// fires no `changed` handler.
|
||
in property <int> scroll-to: 0;
|
||
in property <int> scroll-token: 0;
|
||
|
||
/// Which bucket the grid currently sits in, and how far along the visible
|
||
/// span that is. Rust supplies both: it owns the span, so only it can turn
|
||
/// an instant into the fraction that places the marker.
|
||
in property <int> current-bucket: 0;
|
||
in property <float> current-bucket-fraction: -1;
|
||
/// False until the user has moved the timeline themselves, so the marker
|
||
/// rests at the middle rather than implying a choice not yet made.
|
||
in property <bool> timeline-anchored: false;
|
||
|
||
/// A fraction along the visible span, not a bucket index — the timeline
|
||
/// interpolates so a slow drag tracks the finger rather than snapping.
|
||
callback scrub-fraction(float);
|
||
callback timeline-pan(float);
|
||
callback timeline-zoom(int);
|
||
/// Pinch ratio: above 1 spreads (zoom in), below 1 pinches (zoom out).
|
||
callback timeline-pinch(float);
|
||
/// Ctrl+wheel or pinch over the grid: resize the cells. A signed step,
|
||
/// not a size, so Rust owns the bounds.
|
||
callback zoom-cells(int);
|
||
/// A pinch step: the ratio since the last update, above 1 spreading. The
|
||
/// continuous counterpart of `zoom-cells`, which the wheel steps.
|
||
callback pinch-cells(float);
|
||
/// A pinch has begun, so the press that opened it was not a press.
|
||
callback pinch-started();
|
||
callback columns-changed(int);
|
||
callback sync-now();
|
||
/// The grid scrolled: the first visible image's ordinal in the library.
|
||
/// Rust answers by loading the window around that position.
|
||
callback scrolled(int);
|
||
/// How many cells the viewport can show at once changed — a resize, or a
|
||
/// column-count change.
|
||
///
|
||
/// The *screenful*, not the window to load. How many screenfuls are held
|
||
/// around it, and how close the view may come to an edge of them before
|
||
/// the window moves, are one decision and it is made in Rust — split
|
||
/// across the two languages they drifted apart, and the gap between them
|
||
/// was rows on screen that no loaded cell covered.
|
||
callback viewport-cells-changed(int);
|
||
|
||
// Thumbnail progress is no longer reported here: the batch belongs to a
|
||
// window of the grid rather than to anything the user asked for, and the
|
||
// shell's bar now carries it along with every other running job.
|
||
|
||
// Whole-library indexing, which runs for far longer than one window's
|
||
// thumbnails and is reported separately so the two do not fight over the
|
||
// same line.
|
||
in property <int> sweep-done: 0;
|
||
in property <int> sweep-total: 0;
|
||
/// Pushing shards and the catalog to the server.
|
||
in property <bool> syncing: false;
|
||
property <bool> sweeping: root.sweep-total > 0 && root.sweep-done < root.sweep-total;
|
||
|
||
callback cell-clicked(int);
|
||
/// A star was clicked on a cell: row, and the rating 0..5.
|
||
callback cell-rated(int, int);
|
||
/// 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);
|
||
/// Whether the grid is currently listing the trash rather than the
|
||
/// library. Suppresses the per-cell trash target, which would be inert
|
||
/// there — `plan_trash` skips an already-trashed image — and offering a
|
||
/// control that does nothing is worse than offering none.
|
||
in property <bool> viewing-trash: false;
|
||
/// The trash target was clicked on a cell. Acts on that one photograph,
|
||
/// like the stars beside it — the pointer names it unambiguously, and a
|
||
/// click that quietly trashed a whole selection would be a trap. `Delete`
|
||
/// is the bulk gesture.
|
||
callback cell-trashed(int);
|
||
/// Move the selection to the trash — the `Delete` key.
|
||
callback trash-selection();
|
||
/// A rating or flag key was pressed while the grid had focus. Applies to
|
||
/// the whole selection, which is what makes rating forty frames one
|
||
/// gesture.
|
||
///
|
||
/// Rating and flag travel on one callback because they are one keystroke
|
||
/// as far as the user is concerned; Rust decodes which axis was meant.
|
||
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
|
||
/// star, so neither axis is disturbed by a press on the other.
|
||
callback judged(int, int);
|
||
/// `F2` — rename the collection the grid is scoped to. The key lives with
|
||
/// the grid because that is what holds focus in library mode, but the
|
||
/// rename itself happens in the sidebar.
|
||
callback rename-scope();
|
||
/// Whether the collections sidebar is currently shown. The toggle for it
|
||
/// lives here rather than in the sidebar itself for the obvious reason: a
|
||
/// control inside a closed panel cannot reopen it.
|
||
in property <bool> collections-visible: true;
|
||
callback toggle-collections();
|
||
|
||
callback rescan();
|
||
/// Back to the launch screen, to change library or account.
|
||
callback change-library();
|
||
/// Open the settings page.
|
||
/// TRACES: FR-CAT-10
|
||
/// Whether this platform can import at all. Forwarded to the header, which
|
||
/// hides the button rather than disabling it.
|
||
in property <bool> can-import: false;
|
||
|
||
callback open-import();
|
||
callback open-settings();
|
||
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-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);
|
||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||
/// drag starts, so it always reflects the selection as it is at that moment.
|
||
pure callback drag-payload() -> data-transfer;
|
||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||
/// of them where several are being carried. Composited in Rust, because
|
||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||
in property <image> drag-image;
|
||
/// A drag began on this cell. Lets Rust promote an unselected cell to the
|
||
/// selection before the payload is read.
|
||
callback drag-started(int);
|
||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||
callback drag-finished();
|
||
|
||
// --- rating filter (FR-CAT-6, FR-CULL-4) ---
|
||
//
|
||
// The filter narrows what the grid *queries*, not what it draws: on a
|
||
// remote library, drawing then hiding would still have fetched every
|
||
// thumbnail, which is the cost FR-NC-3 exists to avoid.
|
||
/// Minimum stars to show. 0 shows everything.
|
||
in property <int> filter-min-rating: 0;
|
||
/// Show only images nothing has judged yet — FR-CULL-4's "filter to
|
||
/// unjudged", which is what lets a culling session resume.
|
||
in property <bool> filter-unjudged: false;
|
||
/// 0 no flag filter, 1 picks only, 2 rejects only.
|
||
in property <int> filter-flag: 0;
|
||
/// How many images sit at each star count, index 0 being unrated. Shown
|
||
/// on the filter buttons so the user can see there is something behind a
|
||
/// filter before narrowing to it — a filter that silently empties the
|
||
/// grid reads as broken.
|
||
in property <[int]> rating-counts;
|
||
|
||
/// 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();
|
||
callback filter-min-rating-changed(int);
|
||
callback filter-unjudged-toggled(bool);
|
||
callback filter-flag-changed(int);
|
||
|
||
// --- offline (FR-CAT-9) -------------------------------------------------
|
||
//
|
||
// Offline is a banner rather than a modal or an empty state, because most
|
||
// of the library still works: the shards hold the thumbnails, and rating,
|
||
// flagging, sorting and collecting are catalog writes that never touched
|
||
// the network. Only opening an un-cached original actually fails.
|
||
in property <bool> offline: false;
|
||
in property <string> offline-reason: "";
|
||
in property <string> offline-since: "";
|
||
callback retry-connection();
|
||
|
||
// --- pinning (FR-NC-6a) -----------------------------------------------
|
||
//
|
||
// Whether the collection the grid is scoped to is kept offline, and how
|
||
// far the download has got. Progress is shown because pinning a trip is
|
||
// gigabytes of transfer — a button that appeared to do nothing for twenty
|
||
// minutes would read as broken.
|
||
in property <bool> scope-pinned: false;
|
||
in property <int> pin-done: 0;
|
||
in property <int> pin-total: 0;
|
||
callback toggle-pin-scope();
|
||
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
||
in property <bool> local-only: false;
|
||
in property <int> local-count: 0;
|
||
callback toggle-local-only();
|
||
/// Whether the grid is narrowed to the timeline's visible span.
|
||
in property <bool> range-active: false;
|
||
callback toggle-date-range();
|
||
/// TRACES: FR-CAT-6
|
||
/// The same range as fractions of the span the axis is drawn over, for
|
||
/// the band on the timeline. Negative in both when there is none.
|
||
///
|
||
/// A second representation of one range, which is worth it: the fields
|
||
/// below are how a date is *stated* and the band is how a period is
|
||
/// *found*, and neither is a good way to do the other's job. Both come
|
||
/// from the one filter in Rust, so they cannot drift apart.
|
||
in property <float> range-from-fraction: -1;
|
||
in property <float> range-to-fraction: -1;
|
||
callback timeline-range-changed(float, float);
|
||
/// The ends of the range, as `YYYY-MM-DD`, when one is set.
|
||
///
|
||
/// Strings rather than instants because this is what the user types and
|
||
/// what they read back. Rust parses them and refuses what is not a date;
|
||
/// `range-invalid` is how that refusal reaches the field, since a filter
|
||
/// that silently ignored a typo would show an empty grid and no reason.
|
||
in property <string> range-from;
|
||
in property <string> range-to;
|
||
in property <bool> range-invalid: false;
|
||
/// Both ends at once: they are one range, and applying half of an edit
|
||
/// would filter to a span the user never asked for.
|
||
callback range-edited(string, string);
|
||
|
||
/// How many images are selected, for the header's count.
|
||
in property <int> selected-count: 0;
|
||
|
||
// TRACES: FR-DEV-6
|
||
// Batch-applying copied develop settings to the selection. The clipboard
|
||
// itself belongs to the window — a copy is taken in the develop view and
|
||
// pasted here — so the grid only reports what it has and asks.
|
||
in property <bool> settings-armed: false;
|
||
in property <string> settings-summary;
|
||
callback paste-settings-to-selection();
|
||
/// TRACES: FR-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();
|
||
|
||
// --- the keyboard cursor ------------------------------------------------
|
||
//
|
||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
||
// row of the loaded window, which names a different photograph after every
|
||
// scroll. Rust owns it, because clamping it needs the library's length and
|
||
// moving it may have to swap the window underneath.
|
||
//
|
||
// -1 before the user has taken hold of it, so a fresh grid draws no cursor
|
||
// and the first arrow press picks up where the view already is rather than
|
||
// teleporting to image zero.
|
||
in property <int> cursor: -1;
|
||
/// Move the cursor by a number of images; the flag extends the selection
|
||
/// from the anchor instead of replacing it.
|
||
///
|
||
/// A signed step and nothing else. Home and End are this with a step
|
||
/// longer than the library, which Rust clamps — so this file needs to know
|
||
/// neither how many images there are nor where the loaded window starts.
|
||
callback move-cursor(int, bool);
|
||
/// Open the image under the cursor. `Return`, and the reason the arrows
|
||
/// are worth having: a cull is walk, judge, open, back, without the hand
|
||
/// ever leaving the keyboard.
|
||
callback open-cursor();
|
||
/// Which collection scopes the grid, for the header. Empty means all.
|
||
in property <string> scope-label: "";
|
||
/// Take the selection out of the collection currently being shown. Only
|
||
/// offered when the grid is scoped to one — "remove from library" is not a
|
||
/// thing this button does.
|
||
callback remove-from-collection();
|
||
|
||
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
|
||
//
|
||
// The drag onto the sidebar is the fast way to file photographs, and it is
|
||
// a pointer gesture: a one-finger drag beginning inside the grid belongs to
|
||
// the Flickable that scrolls it, which is the arbitration described at the
|
||
// top of `collections_ui.rs` working exactly as it should. So touch needs a
|
||
// way in that is not a drag, and that is this sheet.
|
||
//
|
||
// The rows are the sidebar's own model, passed through rather than queried
|
||
// again: two lists of collections is one list too many, and the one that
|
||
// goes stale is always the one nobody is looking at.
|
||
in property <[CollectionRow]> collections;
|
||
/// Whether the sheet is up. Local, because it is a disclosure rather than a
|
||
/// preference — nothing outside this file needs to know it is open, and
|
||
/// what closes it is choosing a collection or dismissing it.
|
||
property <bool> filing: false;
|
||
/// Whether the images should *leave* the collection being shown, rather
|
||
/// than being filed in a second one as well. Only meaningful while scoped,
|
||
/// and reset every time the sheet opens: a destructive default that
|
||
/// remembered itself between uses is how photographs go missing.
|
||
property <bool> filing-moves: false;
|
||
/// File the selection: the target collection's id, and whether to take it
|
||
/// out of the one currently being shown.
|
||
callback file-in-collection(int, bool);
|
||
|
||
// --- keywording the selection (FR-CAT-5, FR-CAT-6) ----------------------
|
||
//
|
||
// The catalog has been searchable by keyword since it existed and there was
|
||
// never anywhere to type one. This sheet is that place, and it sits beside
|
||
// the filing sheet above because the two are the same gesture applied to
|
||
// two different kinds of label — pick the photographs, then say what they
|
||
// are — and a user who has learnt one should not have to learn the other.
|
||
//
|
||
// Assign and unassign travel by **name**, not by id. A word typed into the
|
||
// field and a word tapped in the list are then one path through Rust rather
|
||
// than two, and the sheet does not have to invent an id for a keyword that
|
||
// does not exist yet.
|
||
/// The vocabulary, already answered against the current selection.
|
||
in property <[KeywordRow]> keywords;
|
||
/// The sheet is opening: Rust answers by refreshing `keywords` against
|
||
/// whatever is selected *now*.
|
||
///
|
||
/// Pulled on open rather than pushed on every selection change, because the
|
||
/// selection changes on every arrow key and the sheet is shut for almost
|
||
/// all of them — recomputing coverage over a forty-image selection for a
|
||
/// panel nobody is looking at is work the grid cannot afford.
|
||
callback keywords-opened();
|
||
callback assign-keyword(string);
|
||
callback unassign-keyword(string);
|
||
/// Whether the sheet is up. Local, for the same reason `filing` is: it is a
|
||
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||
property <bool> keywording: false;
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
// `elide` + `horizontal-stretch: 1`, like `window-label` below:
|
||
// stretch is what actually lets a `Text` shrink under pressure
|
||
// in Slint, not `elide` alone (see the comment on the header
|
||
// row's Flickable) — without it these three readouts kept
|
||
// their full natural width and were what pushed the sidebar
|
||
// toggle and the "More" button past the right edge on a
|
||
// narrow window, reachable only by knowing to flick-scroll.
|
||
Caption {
|
||
text: root.total > 0 ? root.total + " images" : "";
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
// The selection count, and how to act on it. Both appear only
|
||
// when something is selected — an empty selection has nothing
|
||
// to say and the buttons would be permanently greyed chrome.
|
||
// A live selection is state, not a label — it is the thing the
|
||
// buttons beside it act on — so it keeps `active` rather than
|
||
// dropping to ink with the counts around it.
|
||
Value {
|
||
text: root.selected-count > 0
|
||
? root.selected-count + " selected" : "";
|
||
modified: true;
|
||
compact: true;
|
||
font-weight: 600;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
Label {
|
||
// The scan's own status while it runs; once it is done,
|
||
// thumbnail progress takes the line over — that is the
|
||
// work the user is actually waiting on by then.
|
||
// No per-window preview count: it counted an arbitrary
|
||
// batch, so "48 / 120" described the window's size rather
|
||
// than anything the user cares about. Whole-library
|
||
// indexing is the number worth showing, and the bar below
|
||
// already says that the window itself is still filling.
|
||
text: root.scanning ? root.scan-status
|
||
: (root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.scan-status);
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
// Where in the library the visible window sits. A scrubbable
|
||
// grid is disorienting without it.
|
||
Caption {
|
||
text: root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.window-label;
|
||
horizontal-alignment: right;
|
||
horizontal-stretch: 1;
|
||
overflow: elide;
|
||
}
|
||
|
||
// Expanded: the actions sit in the header as one row. There
|
||
// is room, and a disclosure would be a click in front of a
|
||
// button that was already visible.
|
||
if root.expanded: HeaderActions {
|
||
scope-label: root.scope-label;
|
||
selected-count: root.selected-count;
|
||
scanning: root.scanning;
|
||
syncing: root.syncing;
|
||
scope-pinned: root.scope-pinned;
|
||
can-import: root.can-import;
|
||
settings-armed: root.settings-armed;
|
||
settings-summary: root.settings-summary;
|
||
exporting: root.exporting;
|
||
export-to-server: root.export-to-server;
|
||
export-selection => { root.export-selection(); }
|
||
cancel-export => { root.cancel-export(); }
|
||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||
open-presets => { root.open-presets(); }
|
||
remove-from-collection => { root.remove-from-collection(); }
|
||
select-mode: root.select-mode;
|
||
toggle-select-mode => { root.toggle-select-mode(); }
|
||
add-to-collection => {
|
||
// Reset every time it opens: see `filing-moves`.
|
||
root.filing-moves = false;
|
||
root.filing = true;
|
||
// The disclosure has served its purpose and would
|
||
// otherwise sit behind the sheet, so the row it belongs
|
||
// to is still there when the sheet closes.
|
||
root.actions-open = false;
|
||
}
|
||
add-keyword => {
|
||
// Ask for the vocabulary before showing the sheet, so
|
||
// it is answered against the selection as it stands now
|
||
// rather than as it stood when the grid last loaded.
|
||
root.keywords-opened();
|
||
root.keywording = true;
|
||
root.actions-open = false;
|
||
}
|
||
change-library => { root.change-library(); }
|
||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||
sync-now => { root.sync-now(); }
|
||
rescan => { root.rescan(); }
|
||
open-import => { root.open-import(); }
|
||
open-settings => { root.open-settings(); }
|
||
open-people => { root.open-people(); }
|
||
open-gestures => { root.helping = true; }
|
||
}
|
||
|
||
// Compact: one button in place of six. Labelled rather than a
|
||
// bare glyph, because "⋯" alone in a header of words reads as
|
||
// a truncation of the label beside it — which is exactly what
|
||
// this change exists to stop happening.
|
||
if !root.expanded: Button {
|
||
text: root.actions-open ? "Close" : "More";
|
||
active: root.actions-open;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.actions-open = !root.actions-open; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The compact action row, disclosed by "More" above.
|
||
//
|
||
// A row beneath the header rather than a popup over the grid: a popup
|
||
// needs a dismiss rule, an anchor and a decision about what happens
|
||
// when the window resizes under it, and all this needs to be is the
|
||
// six buttons somewhere they fit. It closes itself when the window
|
||
// widens — see `actions-open`.
|
||
if !root.expanded && root.actions-open: Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
// Scrolls, like the two rows above it. This is the row that exists
|
||
// *because* the header did not fit, so it is the last place to
|
||
// assume six buttons will.
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, actions-row.preferred-width);
|
||
|
||
actions-row := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
alignment: start;
|
||
|
||
HeaderActions {
|
||
centred: true;
|
||
scope-label: root.scope-label;
|
||
selected-count: root.selected-count;
|
||
scanning: root.scanning;
|
||
syncing: root.syncing;
|
||
scope-pinned: root.scope-pinned;
|
||
can-import: root.can-import;
|
||
settings-armed: root.settings-armed;
|
||
settings-summary: root.settings-summary;
|
||
exporting: root.exporting;
|
||
export-to-server: root.export-to-server;
|
||
export-selection => { root.export-selection(); }
|
||
cancel-export => { root.cancel-export(); }
|
||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||
open-presets => { root.open-presets(); }
|
||
remove-from-collection => { root.remove-from-collection(); }
|
||
select-mode: root.select-mode;
|
||
toggle-select-mode => { root.toggle-select-mode(); }
|
||
add-to-collection => {
|
||
// Reset every time it opens: see `filing-moves`.
|
||
root.filing-moves = false;
|
||
root.filing = true;
|
||
// The disclosure has served its purpose and would
|
||
// otherwise sit behind the sheet, so the row it belongs
|
||
// to is still there when the sheet closes.
|
||
root.actions-open = false;
|
||
}
|
||
add-keyword => {
|
||
// Ask for the vocabulary before showing the sheet, so
|
||
// it is answered against the selection as it stands now
|
||
// rather than as it stood when the grid last loaded.
|
||
root.keywords-opened();
|
||
root.keywording = true;
|
||
root.actions-open = false;
|
||
}
|
||
change-library => { root.change-library(); }
|
||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||
sync-now => { root.sync-now(); }
|
||
rescan => { root.rescan(); }
|
||
open-import => { root.open-import(); }
|
||
open-settings => { root.open-settings(); }
|
||
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: 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(); }
|
||
}
|
||
|
||
// 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)
|
||
? "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.
|
||
if root.total == 0: EmptyState {
|
||
headline: root.scanning ? "Scanning…" : "No images found";
|
||
detail: root.scanning ? root.scan-status
|
||
: "Check the library folder and which formats are ticked.";
|
||
}
|
||
|
||
// --- keyboard judgement (FR-CULL-4) -------------------------------
|
||
//
|
||
// `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag.
|
||
// These are the keys every culling tool uses, and muscle memory
|
||
// built elsewhere is worth more here than any improvement.
|
||
//
|
||
// Zero-height rather than wrapping the grid: a FocusScope in this
|
||
// layout would claim a slot and push the grid up, and one *around*
|
||
// the Flickable competes with it for the arrow keys. This holds
|
||
// focus and forwards nothing else.
|
||
//
|
||
// Applies to the **selection**, not to a cell under the pointer —
|
||
// that is what makes rating forty frames a single keystroke, and it
|
||
// matches what the header's count says is selected.
|
||
judge-keys := FocusScope {
|
||
height: 0px;
|
||
// The grid is the primary surface of this screen, so it takes
|
||
// focus on show rather than waiting for a click. Without this
|
||
// the first keystroke of a culling session is swallowed.
|
||
init => { self.focus(); }
|
||
|
||
key-pressed(event) => {
|
||
// TRACES: FR-UI-2 | FR-UI-4
|
||
// Back and Escape close what is open here, innermost
|
||
// first, before the shell above gets to read them as
|
||
// "leave the library". On Android that is the system Back
|
||
// button, and a sheet it walked straight past would leave
|
||
// the user out of the grid with their selection gone.
|
||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||
if (root.keywording) {
|
||
root.keywording = false;
|
||
return accept;
|
||
}
|
||
if (root.filing) {
|
||
root.filing = false;
|
||
return accept;
|
||
}
|
||
if (root.select-mode) {
|
||
root.toggle-select-mode();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
if (event.text == "0") { root.judged(0, -1); return accept; }
|
||
if (event.text == "1") { root.judged(1, -1); return accept; }
|
||
if (event.text == "2") { root.judged(2, -1); return accept; }
|
||
if (event.text == "3") { root.judged(3, -1); return accept; }
|
||
if (event.text == "4") { root.judged(4, -1); return accept; }
|
||
if (event.text == "5") { root.judged(5, -1); return accept; }
|
||
// Case-insensitive: caps lock during a long cull must not
|
||
// silently stop the keys working.
|
||
if (event.text == "p" || event.text == "P") {
|
||
root.judged(-1, 1);
|
||
return accept;
|
||
}
|
||
if (event.text == "x" || event.text == "X") {
|
||
root.judged(-1, 2);
|
||
return accept;
|
||
}
|
||
if (event.text == "u" || event.text == "U") {
|
||
root.judged(-1, 0);
|
||
return accept;
|
||
}
|
||
// Delete moves the selection to the trash folder on the
|
||
// server. Unlike every other key here it is not metadata —
|
||
// it relocates files — but it is also the key every file
|
||
// manager binds to exactly this, and the operation is
|
||
// reversible from the trash view.
|
||
if (event.text == Key.Delete || event.text == Key.Backspace) {
|
||
root.trash-selection();
|
||
return accept;
|
||
}
|
||
// `F2` renames the collection the grid is scoped to — the
|
||
// rename key everywhere else, and the reason it is bound
|
||
// here is that this scope is what holds focus in library
|
||
// mode. Rust ignores it when nothing is scoped.
|
||
if (event.text == Key.F2) {
|
||
root.rename-scope();
|
||
return accept;
|
||
}
|
||
|
||
// --- walking the grid ---------------------------------
|
||
//
|
||
// The keys that make a cull possible without the mouse:
|
||
// arrows move the cursor, shift extends the selection from
|
||
// the anchor, `Return` opens what the cursor is on. The
|
||
// judgement keys above act on the selection, so walking
|
||
// with the arrows and rating as you go is one hand's work.
|
||
//
|
||
// Every one of these is `accept`ed. The `Flickable` scrolls
|
||
// on arrow keys of its own accord, and letting it would
|
||
// move the view out from under a cursor that had not
|
||
// moved — the grid is scrolled *to* the cursor instead,
|
||
// and only when the cursor leaves the viewport.
|
||
//
|
||
// The vertical steps are expressed in columns and rows,
|
||
// which only the grid knows: how far "down" is depends on
|
||
// how wide the window happens to be.
|
||
if (event.text == Key.LeftArrow) {
|
||
root.move-cursor(-1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.RightArrow) {
|
||
root.move-cursor(1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.UpArrow) {
|
||
root.move-cursor(-root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.DownArrow) {
|
||
root.move-cursor(root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageUp) {
|
||
root.move-cursor(-root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageDown) {
|
||
root.move-cursor(root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
// A step longer than the library, clamped at the far end.
|
||
// `total` is what the grid was told the library holds, so
|
||
// this stays honest as it grows.
|
||
if (event.text == Key.Home) {
|
||
root.move-cursor(-root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.End) {
|
||
root.move-cursor(root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.Return) {
|
||
root.open-cursor();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// --- the grid -----------------------------------------------------
|
||
//
|
||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
||
// properly, so dragging a cell drags the cell and dragging the
|
||
// background still flicks the grid. (This is the part a hand-rolled
|
||
// TouchArea gesture could not do — see the drag comments above.)
|
||
if root.total > 0: grid-scroll := Flickable {
|
||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||
// falls through to the Flickable's own scrolling. Two jobs on
|
||
// one gesture, distinguished by the modifier — the convention
|
||
// every image browser uses.
|
||
//
|
||
// Declared *first* so it sits beneath the cells in z-order:
|
||
// their own touch areas still take clicks and drags, and only
|
||
// a wheel event nothing else claimed reaches this.
|
||
zoom-catcher := TouchArea {
|
||
width: 100%;
|
||
height: parent.viewport-height;
|
||
scroll-event(e) => {
|
||
if (e.modifiers.control) {
|
||
root.zoom-cells(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// Two-finger pinch, for tablet: the same gesture the timeline
|
||
// uses, applied to cell size rather than to time.
|
||
//
|
||
// Sized to the **viewport**, like `zoom-catcher` above and for
|
||
// the same reason. `100%` inside a Flickable is the Flickable's
|
||
// own height, and the pinch is delivered to whatever lies under
|
||
// the midpoint of the two fingers — so a handler one screenful
|
||
// tall sat at the top of a viewport thousands of rows long and
|
||
// was under the fingers only while the grid had not been
|
||
// scrolled. Anywhere else the gesture found nothing to land on.
|
||
// 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;
|
||
}
|
||
let top = Theme.gap + self.cursor-row * self.pitch;
|
||
let shown = -self.viewport-y;
|
||
let bottom = max(0px, self.viewport-height - self.height);
|
||
if (top < shown) {
|
||
self.viewport-y = -min(bottom, top);
|
||
} else if (top + self.pitch > shown + self.height) {
|
||
self.viewport-y = -min(bottom, top + self.pitch - self.height);
|
||
}
|
||
}
|
||
|
||
// Also on creation, which is what returning from the develop
|
||
// view needs. `show-library` gates an `if`, so the grid is built
|
||
// anew and `token` is *initialised* to the already-bumped value
|
||
// rather than changing to it — no `changed` handler fires, and
|
||
// without this the restored position would be dropped and the
|
||
// view would sit at the top.
|
||
init => { self.seek(); }
|
||
|
||
// Sized to the **whole library**, not the loaded window. The
|
||
// scrollbar has to represent 23,971 images or there is no way to
|
||
// reach image 20,000 — dragging it must be a real address, and the
|
||
// window is swapped underneath to match.
|
||
// 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.
|
||
property <length> selection-inset:
|
||
root.selected-count > 0 ? 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;
|
||
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.
|
||
border-width: cell-touch.has-hover ? 1px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
VerticalLayout {
|
||
padding: 6px;
|
||
spacing: 4px;
|
||
|
||
Rectangle {
|
||
vertical-stretch: 1;
|
||
background: Theme.ground;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
// Faded while lifted, so the grid reads as the place
|
||
// the photograph came *from* and the cursor as where
|
||
// it is now. `colorize` would flatten it to one
|
||
// tint, which loses the picture; dropping opacity
|
||
// toward the ground keeps it recognisable as a ghost
|
||
// of itself.
|
||
// A rejected frame is held back rather than
|
||
// hidden: the cull is reversible, and a photo
|
||
// that vanished on one keypress would make the
|
||
// gesture frightening to use. Dimming is the
|
||
// cue that reads at grid scale — the cross on
|
||
// the mark confirms it up close.
|
||
opacity: cell.lifted ? 0.25
|
||
: (cell.flag == 2 ? 0.4 : 1.0);
|
||
animate opacity { duration: 120ms; }
|
||
}
|
||
|
||
if !cell.has-thumb: Caption {
|
||
text: cell.unavailable ? "no preview" : "…";
|
||
horizontal-alignment: center;
|
||
}
|
||
|
||
// "Already filed, in this many collections." Without it
|
||
// there is no way to tell a filed photograph from an
|
||
// unfiled one, and the user re-files what is already
|
||
// in place.
|
||
if cell.collection-count > 0: Rectangle {
|
||
x: parent.width - self.width - 4px;
|
||
y: 4px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
background: Theme.selected;
|
||
opacity: 0.9;
|
||
|
||
Text {
|
||
text: cell.collection-count;
|
||
color: Theme.ink;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// Pick or reject, top left — the opposite corner
|
||
// from the collection badge so the two never
|
||
// collide on a cell that carries both.
|
||
FlagMark {
|
||
x: 4px;
|
||
y: 4px;
|
||
flag: cell.flag;
|
||
}
|
||
|
||
}
|
||
|
||
Label {
|
||
text: cell.name;
|
||
emphasised: cell.selected;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// **Selection, 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;
|
||
root.cell-press-ended();
|
||
}
|
||
// `cancel` is the important ending: the Flickable
|
||
// takes the pointer as soon as the finger travels,
|
||
// so without this a scroll that began on a cell
|
||
// would come to rest as a long press and select it.
|
||
if (ev.kind == PointerEventKind.cancel) {
|
||
self.click-pending = false;
|
||
root.cell-press-ended();
|
||
}
|
||
}
|
||
|
||
clicked => { self.click-pending = true; }
|
||
// **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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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 filing sheet (FR-CAT-7, FR-UI-4) -------------------------------
|
||
//
|
||
// "Put these in…", for the times a drag is not available: one finger on a
|
||
// scrolling grid, or a selection made across a scrub where the sidebar has
|
||
// long since been closed to give the photographs the width.
|
||
//
|
||
// Last in the file, so it draws over the grid — and outside the
|
||
// VerticalLayout above, so appearing does not reflow the header and the
|
||
// cells underneath it.
|
||
if root.filing: Rectangle {
|
||
background: #000000CC;
|
||
|
||
// Swallows the taps that miss the card, and closes. First, so the
|
||
// card's own controls sit above it.
|
||
TouchArea {
|
||
clicked => { root.filing = false; }
|
||
}
|
||
|
||
Rectangle {
|
||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||
// Tall enough for the list, but never taller than the window: a
|
||
// library with forty collections must still leave the buttons on
|
||
// screen, which is what the Flickable inside is for.
|
||
height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
border-width: 1px;
|
||
border-color: Theme.rule;
|
||
|
||
// Stops a press on the card reaching the scrim behind it.
|
||
TouchArea { }
|
||
|
||
sheet := VerticalLayout {
|
||
padding: Theme.gap-lg;
|
||
spacing: Theme.gap;
|
||
|
||
Text {
|
||
text: root.selected-count == 1
|
||
? "File 1 photograph in…"
|
||
: "File " + root.selected-count + " photographs in…";
|
||
color: Theme.ink;
|
||
font-size: Theme.text-lg;
|
||
font-weight: 600;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// Filing is a *copy*: a photograph can be in as many
|
||
// collections as it belongs in, which is what a join table
|
||
// means and what the drag has always done. Moving is the
|
||
// exception and has to be asked for, because it is the one
|
||
// that takes something away.
|
||
if root.scope-label != "": Button {
|
||
text: root.filing-moves
|
||
? "Moving out of " + root.scope-label
|
||
: "Also keep in " + root.scope-label;
|
||
active: root.filing-moves;
|
||
clicked => { root.filing-moves = !root.filing-moves; }
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
// A floor, so the list is not squeezed out of existence by
|
||
// the buttons around it on a short window.
|
||
min-height: 120px;
|
||
viewport-height: root.collections.length * (Theme.touch-target + 2px);
|
||
|
||
for row[i] in root.collections: Rectangle {
|
||
y: i * (Theme.touch-target + 2px);
|
||
width: parent.width;
|
||
// A full touch target per row, where the sidebar's
|
||
// equivalent is 26px. The sidebar is a place to look;
|
||
// this is a place to hit once, with a thumb, holding a
|
||
// selection that took a minute to build (FR-UI-3).
|
||
height: Theme.touch-target;
|
||
background: row-touch.pressed ? Theme.pressed
|
||
: (row-touch.has-hover ? Theme.hover : transparent);
|
||
border-radius: Theme.radius-sm;
|
||
// A saved filter's membership is its selector, so it
|
||
// cannot be filed into — the same refusal the sidebar
|
||
// makes on a drag, made here before the tap rather
|
||
// than after it.
|
||
opacity: row.smart ? 0.4 : 1.0;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap-sm + row.depth * Theme.indent;
|
||
padding-right: Theme.gap-sm;
|
||
spacing: Theme.gap-sm;
|
||
|
||
Icon {
|
||
name: row.smart ? "collection-smart" : "collection";
|
||
ink: Theme.ink-faint;
|
||
size: 14px;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
Text {
|
||
text: row.name;
|
||
color: Theme.ink;
|
||
font-size: Theme.text;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
Text {
|
||
text: row.smart ? "computed"
|
||
: (row.deep-count > 0 ? row.deep-count + "" : "");
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
row-touch := TouchArea {
|
||
enabled: !row.smart;
|
||
clicked => {
|
||
root.file-in-collection(row.id, root.filing-moves);
|
||
root.filing = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
if root.collections.length == 0: Text {
|
||
text: "No collections yet. Make one with + in the sidebar.";
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
wrap: word-wrap;
|
||
width: parent.width;
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Button {
|
||
text: "Cancel";
|
||
clicked => { root.filing = false; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the keywording sheet (FR-CAT-5, FR-CAT-6) --------------------------
|
||
//
|
||
// "These are of…". Deliberately the same card, scrim and dismissal as the
|
||
// filing sheet above: a user who has filed a selection already knows how
|
||
// this works, and a second idiom for the same gesture would be a second
|
||
// thing to learn for no gain.
|
||
//
|
||
// It stays open after each word, where the filing sheet closes. Filing is
|
||
// one choice; keywording is usually several — "puffin", "Látrabjarg",
|
||
// "2026" — and a sheet that shut after each one would have to be reopened,
|
||
// and the selection re-confirmed, three times over.
|
||
if root.keywording: Rectangle {
|
||
background: #000000CC;
|
||
|
||
// Swallows the taps that miss the card, and closes. First, so the
|
||
// card's own controls sit above it.
|
||
TouchArea {
|
||
clicked => { root.keywording = false; }
|
||
}
|
||
|
||
Rectangle {
|
||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||
height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
border-width: 1px;
|
||
border-color: Theme.rule;
|
||
|
||
// Stops a press on the card reaching the scrim behind it.
|
||
TouchArea { }
|
||
|
||
kw-sheet := VerticalLayout {
|
||
padding: Theme.gap-lg;
|
||
spacing: Theme.gap;
|
||
|
||
Text {
|
||
text: root.selected-count == 1
|
||
? "Keywords for 1 photograph"
|
||
: "Keywords for " + root.selected-count + " photographs";
|
||
color: Theme.ink;
|
||
font-size: Theme.text-lg;
|
||
font-weight: 600;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// Typing a word applies it, whether or not it already exists.
|
||
// One field for both, because "is this keyword new?" is a
|
||
// question about the catalog and not about what the user meant,
|
||
// and Rust can answer it without being asked.
|
||
//
|
||
// The field clears itself on accept so the next word can be
|
||
// typed straight after — keywording a shoot is a run of them.
|
||
new-keyword := Field {
|
||
placeholder: "Type a keyword and press return";
|
||
accepted(text) => {
|
||
root.assign-keyword(text);
|
||
self.text = "";
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
// A floor, so the list is not squeezed out of existence by
|
||
// the field and the button around it on a short window.
|
||
min-height: 120px;
|
||
viewport-height: root.keywords.length * (Theme.touch-target + 2px);
|
||
|
||
for word[i] in root.keywords: Rectangle {
|
||
y: i * (Theme.touch-target + 2px);
|
||
width: parent.width;
|
||
// A full touch target per row, for the same reason the
|
||
// filing sheet uses one: this is a place to hit once,
|
||
// with a thumb, holding a selection that took a minute
|
||
// to build (FR-UI-3).
|
||
height: Theme.touch-target;
|
||
background: kw-touch.pressed ? Theme.pressed
|
||
: (kw-touch.has-hover ? Theme.hover : transparent);
|
||
border-radius: Theme.radius-sm;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap-sm;
|
||
padding-right: Theme.gap-sm;
|
||
spacing: Theme.gap-sm;
|
||
|
||
// Tick, dash, or nothing — the three states of
|
||
// `coverage`, drawn as three different marks rather
|
||
// than as two. A half-applied keyword shown as
|
||
// applied is a lie about photographs the user
|
||
// cannot see from here.
|
||
Rectangle {
|
||
width: 16px;
|
||
y: (parent.height - self.height) / 2;
|
||
height: 16px;
|
||
border-radius: Theme.radius-sm;
|
||
border-width: 1px;
|
||
border-color: word.coverage == 0 ? Theme.rule : Theme.active;
|
||
background: word.coverage == 2 ? Theme.active : transparent;
|
||
|
||
// The dash for "some of them". A bar rather
|
||
// than a tick, because a tick at half strength
|
||
// reads as a rendering artefact.
|
||
if word.coverage == 1: Rectangle {
|
||
width: 8px;
|
||
height: 2px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.active;
|
||
}
|
||
if word.coverage == 2: Icon {
|
||
name: "check";
|
||
ink: Theme.surface;
|
||
size: 12px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: word.name;
|
||
color: Theme.ink;
|
||
font-size: Theme.text;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
// "3 of 12" only where it says something the mark
|
||
// does not. For a word the whole selection carries,
|
||
// or none of it, the mark has already said it and
|
||
// the number would be noise on every row.
|
||
Text {
|
||
text: word.coverage == 1
|
||
? word.selected-count + " of " + root.selected-count
|
||
: (word.image-count > 0 ? word.image-count + "" : "");
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// One target for both directions. A word the selection
|
||
// fully carries comes off; anything else goes on — so a
|
||
// partly-applied keyword is completed rather than
|
||
// removed, which is what a user tapping a dash means
|
||
// nine times in ten, and the tenth is one more tap
|
||
// away.
|
||
kw-touch := TouchArea {
|
||
clicked => {
|
||
if (word.coverage == 2) {
|
||
root.unassign-keyword(word.name);
|
||
} else {
|
||
root.assign-keyword(word.name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if root.keywords.length == 0: Text {
|
||
text: "No keywords yet. Type one above to make the first.";
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
wrap: word-wrap;
|
||
width: parent.width;
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Button {
|
||
text: "Done";
|
||
clicked => { root.keywording = false; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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.
|
||
//
|
||
// **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.
|
||
if root.selected-count > 0: 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.
|
||
TouchArea { }
|
||
|
||
HorizontalLayout {
|
||
width: 100%;
|
||
height: 100%;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
alignment: start;
|
||
|
||
// 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;
|
||
}
|
||
|
||
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.
|
||
if !root.ranging: Value {
|
||
text: root.selected-count + (root.selected-count == 1
|
||
? " photograph selected" : " photographs selected");
|
||
modified: true;
|
||
font-weight: 600;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
|
||
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: 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: 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: Button {
|
||
text: "Select to…";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.ranging = true; }
|
||
}
|
||
|
||
// 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.
|
||
// No longer "…from selection": the sheet it opens says
|
||
// "New collection holding 12 photographs" at the top, so a
|
||
// button repeating it is width the strip does not have on a
|
||
// tablet in portrait.
|
||
if !root.ranging: Button {
|
||
text: "New collection";
|
||
primary: true;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.naming = true; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|