Two real conflicts, both from work that landed either side of the same lines rather than against them. `lib.rs`: the settings controller was hoisted above the People screen's wiring, and Android's thumbnail-tier eviction registered itself at the same point. Independent, so both stay. `library.rs`: manual collection ordering and burst folding each added a clause to the same two queries. The scoped range read now carries both — the folding matters there for one step further on than it does in the grid, because a collapsed burst is one cell, so an ordinal counted over a list still holding every frame names a photograph several places away from the one the user pointed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3787 lines
182 KiB
Plaintext
3787 lines
182 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";
|
||
|
||
// 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,
|
||
// The cell a shift-click would extend *from* — the last one plainly
|
||
// clicked. Marked because otherwise the gesture has a hidden operand: the
|
||
// user picks the far end of a run with no way to see which end the range
|
||
// is being measured from, and a range that comes out wrong gives no clue
|
||
// why. At most one cell in the library carries this, and often none of
|
||
// them is on screen — which is itself the answer to "why did that select
|
||
// so much".
|
||
anchor: bool,
|
||
// How many collections this image belongs to. An image can be in many at
|
||
// once, and without a cue the grid gives no hint that a photograph has
|
||
// already been filed — the user re-files it, or hunts for where it went.
|
||
collection-count: int,
|
||
// This cell is one of the images currently being dragged. It reads as
|
||
// *lifted out*: desaturated and shrunk in place, so the grid shows where
|
||
// the photographs came from while the cursor shows them in full colour.
|
||
lifted: bool,
|
||
// Stars, 0..5. Zero is *unrated* — a state of its own, not a low score,
|
||
// and what "filter to unjudged" selects (FR-CULL-4).
|
||
rating: int,
|
||
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
|
||
// four-star frame is a normal thing to do mid-cull.
|
||
flag: int,
|
||
// 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; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A row of five stars, readable at a glance and clickable to set a rating.
|
||
//
|
||
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
|
||
// status by hue alone, and the palette is achromatic anyway — so a set star is
|
||
// a solid star at `active` and an unset one is an outline at `ink-faint`. The
|
||
// two differ in both shape and luminance, which survives greyscale and low
|
||
// vision alike.
|
||
//
|
||
// **Unrated draws nothing until hovered.** A grid of 120 cells each showing
|
||
// five empty stars is a wall of chrome competing with the photographs; a
|
||
// freshly scanned library would look like a spreadsheet. The strip appears on
|
||
// hover, so an unjudged cell is quiet and a judged one is legible from across
|
||
// the room.
|
||
export component StarStrip inherits Rectangle {
|
||
in property <int> rating: 0;
|
||
/// Show the empty stars even at zero — used while the pointer is over the
|
||
/// cell, so there is something to aim at.
|
||
in property <bool> show-empty: false;
|
||
/// Whether clicking sets a rating. False in a read-only context.
|
||
in property <bool> interactive: true;
|
||
/// Whether to offer the trash target at all. Hidden in the trash view,
|
||
/// where these photographs are already there — `plan_trash` would skip
|
||
/// them anyway, so the control would be inert, and an inert control that
|
||
/// looks live is worse than no control.
|
||
in property <bool> can-trash: true;
|
||
|
||
/// A star was clicked: the rating it stands for, 1..5.
|
||
callback rate(int);
|
||
/// The trash target was clicked. Named separately from [`rate`] rather
|
||
/// than being rating `-1`: this moves a file on the server, and a callback
|
||
/// that could mean either "set a rating" or "delete a photograph"
|
||
/// depending on the sign is one typo away from the wrong one.
|
||
callback trash();
|
||
|
||
// One star's drawn box. The *ink* stays small — five 44px stars would be
|
||
// 220px wide and swamp a 180px cell — while the hit area below grows to
|
||
// `Theme.touch-target`, which is the same split `Button` and `FormatCheck`
|
||
// make for the same FR-UI-3 reason.
|
||
property <length> star: 22px;
|
||
|
||
// Separation between the trash target and ★1.
|
||
//
|
||
// **This gap is load-bearing.** The star targets are 44px over a 22px
|
||
// cell, so they deliberately overlap and a near-miss lands one star out —
|
||
// harmless, same control, corrected by clicking again. That reasoning does
|
||
// not survive a neighbour that *moves a file*, so trash is held off the
|
||
// scale by a gap wider than the overhang it would otherwise share with
|
||
// ★1. A slip between them hits nothing at all, which is the correct
|
||
// outcome for an ambiguous press next to a destructive target.
|
||
property <length> trash-gap: 14px;
|
||
|
||
height: root.star;
|
||
// Sized to its content so the cell's layout does not reserve space for a
|
||
// strip that may be invisible.
|
||
width: 5 * root.star + (root.can-trash ? root.star + root.trash-gap : 0px);
|
||
// A ground behind the stars: they sit over a photograph that may be white
|
||
// at that point, and an outline star on a bright sky is invisible. Also
|
||
// makes the strip read as one control rather than five loose marks.
|
||
background: root.visible ? Theme.surface.with-alpha(0.75) : transparent;
|
||
border-radius: Theme.radius;
|
||
visible: root.rating > 0 || root.show-empty;
|
||
|
||
HorizontalLayout {
|
||
// Trash sits at the *left*, before the scale rather than beyond its
|
||
// top: reading left to right it is "remove this" and then a rising
|
||
// scale, which keeps ★5 at the end where a rating scale is expected
|
||
// to peak. Putting it past ★5 would make the strip read as a
|
||
// six-point scale whose last stop deletes.
|
||
Rectangle {
|
||
width: root.can-trash ? root.star : 0px;
|
||
height: root.star;
|
||
visible: root.can-trash;
|
||
|
||
Icon {
|
||
name: "trash";
|
||
// Reject and trash are the two destructive ends of this UI and
|
||
// share the palette's one hue, so the gesture reads the same
|
||
// in both places (NFR-A11Y-3: the shape carries it, not the
|
||
// colour).
|
||
ink: Theme.warn-ink;
|
||
size: 13px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
// Deliberately *not* grown to `touch-target`. Every other
|
||
// target here overhangs to meet FR-UI-3, but that requirement
|
||
// is about reaching a control with a thumb — it is not a
|
||
// reason to make a destructive one easier to hit by accident
|
||
// than the thing beside it. 22px plus the gap is a real target
|
||
// without reaching into ★1's band.
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
clicked => { root.trash(); }
|
||
}
|
||
}
|
||
|
||
// The gap itself, inert — no TouchArea, so a press here does nothing
|
||
// rather than resolving to whichever neighbour is closer.
|
||
Rectangle { width: root.can-trash ? root.trash-gap : 0px; }
|
||
|
||
for n[i] in [1, 2, 3, 4, 5]: Rectangle {
|
||
width: root.star;
|
||
height: root.star;
|
||
|
||
Icon {
|
||
// Solid versus outline: the shape says it, not the colour.
|
||
name: root.rating >= n ? "star" : "star-outline";
|
||
ink: root.rating >= n ? Theme.active : Theme.ink-dim;
|
||
// Large enough to hit the difference between filled and empty
|
||
// at arm's length on a tablet; the two differ in fill, which
|
||
// needs more pixels to read than a difference in shape would.
|
||
size: 14px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
// Grown past the drawn star to meet FR-UI-3's 44pt minimum, and
|
||
// centred on it. The overhang overlaps its neighbours, so the
|
||
// *later* star wins the shared band — which is why this is
|
||
// acceptable here: adjacent targets belong to the same control and
|
||
// a near-miss sets a rating one star out, not something unrelated.
|
||
//
|
||
// Vertical overhang spills outside the strip onto the thumbnail,
|
||
// which carries no hit area of its own — the cell's TouchArea is
|
||
// below this in z-order.
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
width: max(parent.width, Theme.touch-target);
|
||
height: max(parent.height, Theme.touch-target);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
// Clicking the star already set clears the rating — the
|
||
// gesture every photo tool uses for "undo that", and without
|
||
// it the only way back to unrated is the keyboard.
|
||
clicked => { root.rate(root.rating == n ? 0 : n); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The pick/reject mark.
|
||
//
|
||
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
||
// a tick and a cross are distinguishable without hue, and a reject reads as a
|
||
// reject in greyscale. A reject also dims its whole cell, which is the cue
|
||
// that carries at grid scale — the mark is confirmation, not the primary
|
||
// signal.
|
||
export component FlagMark inherits Rectangle {
|
||
in property <int> flag: 0;
|
||
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
visible: root.flag > 0;
|
||
background: Theme.surface;
|
||
opacity: 0.92;
|
||
|
||
Icon {
|
||
name: root.flag == 2 ? "cross" : "check";
|
||
// Reject earns the one hue in the palette: it is the destructive end
|
||
// of the axis and the thing a user must not mistake for a pick.
|
||
ink: root.flag == 2 ? Theme.warn-ink : Theme.active;
|
||
size: 9px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
}
|
||
|
||
// The header's action buttons, in one place so they can be drawn in two.
|
||
//
|
||
// A tablet in portrait is 768 logical pixels wide, and the header wants a
|
||
// title, three status readouts and six buttons. Laid out in one row they all
|
||
// shrink to their minimum and elide — "Change library" arrives as "Change
|
||
// li…" — and the row still overflows its own width. Slint's HorizontalLayout
|
||
// has no wrap and no overflow, so the row has to be told what to drop.
|
||
//
|
||
// Extracted rather than duplicated because the alternative is six buttons
|
||
// written twice with their visibility rules and callbacks copied, and the
|
||
// copy that gets forgotten is the one behind the disclosure nobody opens
|
||
// while testing.
|
||
component HeaderActions inherits HorizontalLayout {
|
||
in property <string> scope-label;
|
||
in property <int> selected-count: 0;
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Whether taps are selecting rather than opening. The non-gesture half of
|
||
/// touch multi-selection: the long press is the quick way in, and this is
|
||
/// the way that can be *found*.
|
||
in property <bool> select-mode: false;
|
||
in property <bool> scanning: false;
|
||
in property <bool> syncing: false;
|
||
in property <bool> scope-pinned: false;
|
||
/// TRACES: FR-DEV-6
|
||
/// Whether settings have been copied, and what pasting them would apply.
|
||
in property <bool> settings-armed: false;
|
||
in property <string> settings-summary;
|
||
/// TRACES: FR-EXP-7
|
||
/// Whether a batch is running, and where its files are going.
|
||
in property <bool> exporting: false;
|
||
in property <bool> export-to-server: false;
|
||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||
/// is sized to its content.
|
||
in property <bool> centred: true;
|
||
in property <length> row-height: 44px;
|
||
|
||
callback export-selection();
|
||
callback cancel-export();
|
||
callback paste-settings-to-selection();
|
||
/// 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();
|
||
|
||
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(); }
|
||
}
|
||
|
||
// 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();
|
||
|
||
// --- 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.
|
||
///
|
||
/// The last argument is **whether a finger did it**, and it is here because
|
||
/// a finger and a pointer need different rules about what counts as a tap.
|
||
/// A mouse click is over in tens of milliseconds and means it; a hand
|
||
/// brushing a tablet on its way somewhere else produces exactly the same
|
||
/// press-and-release, and used to land the user in develop. Rust holds the
|
||
/// rule (`collections_ui::TAP_MIN_MS`) beside the hold timer it has to sit
|
||
/// between — this file only reports which kind of contact it was.
|
||
callback cell-pressed(int, bool, bool, bool);
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
||
/// the finger travelled. Cancels the long-press timer that would otherwise
|
||
/// turn a scroll into a selection. Rust owns that timer: Slint has no
|
||
/// long-press gesture, and a hand-rolled one here would need a `Timer` per
|
||
/// visible cell.
|
||
callback cell-press-ended();
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Two taps on the same cell. In selection mode this is the touch form of
|
||
/// shift-click: everything from where the selection started to here. Rust
|
||
/// decides — outside that mode a double tap is two ordinary clicks and the
|
||
/// first has already opened the image.
|
||
callback cell-double-clicked(int);
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Whether a tap selects rather than opens.
|
||
///
|
||
/// Held in Rust beside the selection it modifies, so the long press and the
|
||
/// header's button are two doors into one state rather than two states that
|
||
/// can disagree. In this mode a plain tap toggles a cell — exactly what
|
||
/// ctrl-click does with a pointer, which is why the press below passes it
|
||
/// as the ctrl flag rather than as a third selection policy.
|
||
in property <bool> select-mode: false;
|
||
callback toggle-select-mode();
|
||
/// TRACES: FR-CAT-5
|
||
// --- 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.
|
||
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(); }
|
||
}
|
||
|
||
// 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(); }
|
||
}
|
||
}
|
||
}
|
||
|
||
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.
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- what is selected, and what to do with it --------------------
|
||
//
|
||
// The count and these actions used to live only in the header row,
|
||
// which scrolls sideways: on a tablet "12 selected" and everything
|
||
// beside it sat past the right-hand edge, so a selection was something
|
||
// you could make and then not see. A selection you cannot see is one
|
||
// you act on by accident.
|
||
//
|
||
// Its own strip, like the date range's, appearing only while there is
|
||
// a selection — so it costs nothing the rest of the time, and when it
|
||
// is there it has the width of the window rather than the tail of a
|
||
// row that did not fit.
|
||
if root.selected-count > 0: Rectangle {
|
||
height: 40px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
width: 100%;
|
||
height: 100%;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
alignment: start;
|
||
|
||
// 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.
|
||
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.
|
||
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; }
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// --- the date range's ends ---------------------------------------
|
||
//
|
||
// Its own strip, a sibling of the chips rather than a child of them.
|
||
//
|
||
// Twice now this control has looked broken. First it narrowed to the
|
||
// whole library, because it took its span from a timeline zoom that
|
||
// is zero until someone zooms. Then the fields that fixed that went
|
||
// into the chip row — which is 34px tall and scrolls sideways, so
|
||
// they were both clipped and off past the right-hand edge.
|
||
//
|
||
// They are no longer the only way to state a range — the band on the
|
||
// axis is, and on a phone it is the only usable one. These stay for
|
||
// the two things dragging cannot do: name an exact day, and say what
|
||
// the range currently is in words rather than as a position.
|
||
//
|
||
// A `Rectangle` stacks its children at the origin rather than laying
|
||
// them out, which is why putting a second row inside the chips' one
|
||
// drew it over them instead of under them. This is a row of the
|
||
// header's `VerticalLayout`, so it gets a line of its own and the
|
||
// width of the window.
|
||
if root.range-active: Rectangle {
|
||
height: 40px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
width: 100%;
|
||
height: 100%;
|
||
// The same leading inset the chips above use, so the two rows
|
||
// start on one vertical line rather than a few pixels apart.
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
// Fixed-width fields and a two-letter word: left-aligned, so
|
||
// they sit under the chip that turned them on instead of
|
||
// spreading across the window.
|
||
alignment: start;
|
||
|
||
Field {
|
||
width: 108px;
|
||
y: (parent.height - self.height) / 2;
|
||
text: root.range-from;
|
||
placeholder: "YYYY-MM-DD";
|
||
accepted(t) => { root.range-edited(t, root.range-to); }
|
||
}
|
||
|
||
Caption {
|
||
text: "to";
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
Field {
|
||
width: 108px;
|
||
y: (parent.height - self.height) / 2;
|
||
text: root.range-to;
|
||
placeholder: "YYYY-MM-DD";
|
||
accepted(t) => { root.range-edited(root.range-from, t); }
|
||
}
|
||
|
||
if root.range-invalid: Caption {
|
||
text: "not a date";
|
||
warn: true;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- progress -----------------------------------------------------
|
||
//
|
||
// The bar that used to sit here is now the shell's, drawn across the
|
||
// top of every view from the activity register (see `activity.rs`).
|
||
// Two reasons it moved. It only ever knew about the three things the
|
||
// grid happens to report — a download running while the user was in
|
||
// develop drew nothing anywhere — and a second bar here would now say
|
||
// the same thing twice, one line apart.
|
||
//
|
||
// The grid keeps its *words*: "indexing 4000 / 17000" in the header
|
||
// above says which work is running, which a bar cannot.
|
||
|
||
// --- pinning ------------------------------------------------------
|
||
//
|
||
// Its own line, with the count spelled out: the shell's bar says that
|
||
// something is transferring, and this says how much of what.
|
||
if root.pin-total > 0: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Caption {
|
||
text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
ProgressBar {
|
||
fraction: root.pin-done / max(1, root.pin-total);
|
||
y: (parent.height - self.height) / 2;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- offline ------------------------------------------------------
|
||
//
|
||
// Above the scan error, and it suppresses it: when the server is
|
||
// unreachable the scan failure is a *consequence*, and showing both
|
||
// reports one problem twice while implying two.
|
||
if root.offline: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Caption {
|
||
text: "Offline — showing what is stored on this device"
|
||
+ (root.offline-since != "" ? " (" + root.offline-since + ")" : "");
|
||
warn: true;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The transport's own words. Usually specific enough to act on
|
||
// — "connection refused" and "dns error" send the user to
|
||
// different places — where a bare "offline" leaves them
|
||
// guessing whether it is their wifi or the server.
|
||
Caption {
|
||
text: root.offline-reason;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
Button {
|
||
text: "Retry";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.retry-connection(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- error --------------------------------------------------------
|
||
if root.scan-error != "" && !root.offline: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
Caption {
|
||
text: root.scan-error;
|
||
warn: true;
|
||
horizontal-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// --- body: sidebar beside the grid --------------------------------
|
||
//
|
||
// The capture-time axis is furniture, not a strip under the images: a
|
||
// scroll position is relative, a date is absolute, and this is the
|
||
// primary way of moving through the library.
|
||
HorizontalLayout {
|
||
vertical-stretch: 1;
|
||
|
||
// Always present, never conditional on having bars.
|
||
//
|
||
// Creating it on `timeline.length > 0` made the sidebar's 96px
|
||
// appear the moment the first dates were recorded, which narrowed
|
||
// the grid — changing `columns` and `viewport-cells`, both of which call
|
||
// back into Rust to reload the window. The first sweep flush
|
||
// therefore landed a reload storm on top of the initial thumbnail
|
||
// batch. Reserving the column costs 96px on an undated library and
|
||
// keeps the grid's width stable while dates arrive.
|
||
//
|
||
// An empty `bars` already renders as bare furniture: the `for`
|
||
// loops produce nothing and the gestures index an empty array only
|
||
// under a pointer that has no bar to land on.
|
||
Timeline {
|
||
bars: root.timeline;
|
||
range-label: root.timeline-label;
|
||
current-start: root.current-bucket;
|
||
current-fraction: root.current-bucket-fraction;
|
||
anchored: root.timeline-anchored;
|
||
range-from: root.range-from-fraction;
|
||
range-to: root.range-to-fraction;
|
||
|
||
range-changed(a, b) => { root.timeline-range-changed(a, b); }
|
||
scrub-to(f) => { root.scrub-fraction(f); }
|
||
pinch(r) => { root.timeline-pinch(r); }
|
||
pan(d) => { root.timeline-pan(d); }
|
||
zoom(d) => { root.timeline-zoom(d); }
|
||
}
|
||
|
||
grid-area := VerticalLayout {
|
||
horizontal-stretch: 1;
|
||
|
||
// --- empty state --------------------------------------------------
|
||
//
|
||
// "Still scanning" and "scanned, found nothing" are different answers.
|
||
// Conflating them is how a working scan looks broken.
|
||
if root.total == 0: EmptyState {
|
||
headline: root.scanning ? "Scanning…" : "No images found";
|
||
detail: root.scanning ? root.scan-status
|
||
: "Check the library folder and which formats are ticked.";
|
||
}
|
||
|
||
// --- keyboard judgement (FR-CULL-4) -------------------------------
|
||
//
|
||
// `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag.
|
||
// These are the keys every culling tool uses, and muscle memory
|
||
// built elsewhere is worth more here than any improvement.
|
||
//
|
||
// Zero-height rather than wrapping the grid: a FocusScope in this
|
||
// layout would claim a slot and push the grid up, and one *around*
|
||
// the Flickable competes with it for the arrow keys. This holds
|
||
// focus and forwards nothing else.
|
||
//
|
||
// Applies to the **selection**, not to a cell under the pointer —
|
||
// that is what makes rating forty frames a single keystroke, and it
|
||
// matches what the header's count says is selected.
|
||
judge-keys := FocusScope {
|
||
height: 0px;
|
||
// The grid is the primary surface of this screen, so it takes
|
||
// focus on show rather than waiting for a click. Without this
|
||
// the first keystroke of a culling session is swallowed.
|
||
init => { self.focus(); }
|
||
|
||
key-pressed(event) => {
|
||
// TRACES: FR-UI-2 | FR-UI-4
|
||
// Back and Escape close what is open here, innermost
|
||
// first, before the shell above gets to read them as
|
||
// "leave the library". On Android that is the system Back
|
||
// button, and a sheet it walked straight past would leave
|
||
// the user out of the grid with their selection gone.
|
||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||
if (root.keywording) {
|
||
root.keywording = false;
|
||
return accept;
|
||
}
|
||
if (root.filing) {
|
||
root.filing = false;
|
||
return accept;
|
||
}
|
||
if (root.select-mode) {
|
||
root.toggle-select-mode();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
if (event.text == "0") { root.judged(0, -1); return accept; }
|
||
if (event.text == "1") { root.judged(1, -1); return accept; }
|
||
if (event.text == "2") { root.judged(2, -1); return accept; }
|
||
if (event.text == "3") { root.judged(3, -1); return accept; }
|
||
if (event.text == "4") { root.judged(4, -1); return accept; }
|
||
if (event.text == "5") { root.judged(5, -1); return accept; }
|
||
// Case-insensitive: caps lock during a long cull must not
|
||
// silently stop the keys working.
|
||
if (event.text == "p" || event.text == "P") {
|
||
root.judged(-1, 1);
|
||
return accept;
|
||
}
|
||
if (event.text == "x" || event.text == "X") {
|
||
root.judged(-1, 2);
|
||
return accept;
|
||
}
|
||
if (event.text == "u" || event.text == "U") {
|
||
root.judged(-1, 0);
|
||
return accept;
|
||
}
|
||
// Delete moves the selection to the trash folder on the
|
||
// server. Unlike every other key here it is not metadata —
|
||
// it relocates files — but it is also the key every file
|
||
// manager binds to exactly this, and the operation is
|
||
// reversible from the trash view.
|
||
if (event.text == Key.Delete || event.text == Key.Backspace) {
|
||
root.trash-selection();
|
||
return accept;
|
||
}
|
||
// `F2` renames the collection the grid is scoped to — the
|
||
// rename key everywhere else, and the reason it is bound
|
||
// here is that this scope is what holds focus in library
|
||
// mode. Rust ignores it when nothing is scoped.
|
||
if (event.text == Key.F2) {
|
||
root.rename-scope();
|
||
return accept;
|
||
}
|
||
|
||
// --- walking the grid ---------------------------------
|
||
//
|
||
// The keys that make a cull possible without the mouse:
|
||
// arrows move the cursor, shift extends the selection from
|
||
// the anchor, `Return` opens what the cursor is on. The
|
||
// judgement keys above act on the selection, so walking
|
||
// with the arrows and rating as you go is one hand's work.
|
||
//
|
||
// Every one of these is `accept`ed. The `Flickable` scrolls
|
||
// on arrow keys of its own accord, and letting it would
|
||
// move the view out from under a cursor that had not
|
||
// moved — the grid is scrolled *to* the cursor instead,
|
||
// and only when the cursor leaves the viewport.
|
||
//
|
||
// The vertical steps are expressed in columns and rows,
|
||
// which only the grid knows: how far "down" is depends on
|
||
// how wide the window happens to be.
|
||
if (event.text == Key.LeftArrow) {
|
||
root.move-cursor(-1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.RightArrow) {
|
||
root.move-cursor(1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.UpArrow) {
|
||
root.move-cursor(-root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.DownArrow) {
|
||
root.move-cursor(root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageUp) {
|
||
root.move-cursor(-root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageDown) {
|
||
root.move-cursor(root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
// A step longer than the library, clamped at the far end.
|
||
// `total` is what the grid was told the library holds, so
|
||
// this stays honest as it grows.
|
||
if (event.text == Key.Home) {
|
||
root.move-cursor(-root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.End) {
|
||
root.move-cursor(root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.Return) {
|
||
root.open-cursor();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// --- the grid -----------------------------------------------------
|
||
//
|
||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
||
// properly, so dragging a cell drags the cell and dragging the
|
||
// background still flicks the grid. (This is the part a hand-rolled
|
||
// TouchArea gesture could not do — see the drag comments above.)
|
||
if root.total > 0: grid-scroll := Flickable {
|
||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||
// falls through to the Flickable's own scrolling. Two jobs on
|
||
// one gesture, distinguished by the modifier — the convention
|
||
// every image browser uses.
|
||
//
|
||
// Declared *first* so it sits beneath the cells in z-order:
|
||
// their own touch areas still take clicks and drags, and only
|
||
// a wheel event nothing else claimed reaches this.
|
||
zoom-catcher := TouchArea {
|
||
width: 100%;
|
||
height: parent.viewport-height;
|
||
scroll-event(e) => {
|
||
if (e.modifiers.control) {
|
||
root.zoom-cells(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// Two-finger pinch, for tablet: the same gesture the timeline
|
||
// uses, applied to cell size rather than to time.
|
||
//
|
||
// Sized to the **viewport**, like `zoom-catcher` above and for
|
||
// the same reason. `100%` inside a Flickable is the Flickable's
|
||
// own height, and the pinch is delivered to whatever lies under
|
||
// the midpoint of the two fingers — so a handler one screenful
|
||
// tall sat at the top of a viewport thousands of rows long and
|
||
// was under the fingers only while the grid had not been
|
||
// scrolled. Anywhere else the gesture found nothing to land on.
|
||
grid-pinch := ScaleRotateGestureHandler {
|
||
width: 100%;
|
||
height: parent.viewport-height;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => {
|
||
self.last-scale = 1.0;
|
||
root.pinching = true;
|
||
settle.running = false;
|
||
// The finger that opened this gesture landed on a cell
|
||
// and selected it. It was reaching for the grid, not
|
||
// for that photograph.
|
||
root.pinch-started();
|
||
}
|
||
// Continuous, not stepped. Thresholding this into ±1 zoom
|
||
// steps meant the grid lurched 25% at a time and sat still
|
||
// in between, which is the whole of "pinching is not
|
||
// smooth". The ratio since the last update is what tracks
|
||
// the fingers; where the drawn cell lands is still a whole
|
||
// number of columns, because the columns divide the width.
|
||
updated => {
|
||
root.pinch-cells(self.scale / max(0.01, self.last-scale));
|
||
self.last-scale = self.scale;
|
||
}
|
||
// The latch outlives the gesture — see `pinching`.
|
||
ended => {
|
||
self.last-scale = 1.0;
|
||
settle.running = true;
|
||
}
|
||
cancelled => {
|
||
self.last-scale = 1.0;
|
||
settle.running = true;
|
||
}
|
||
}
|
||
|
||
// Follow a requested position. Without this a scrub moves the
|
||
// *loaded window* while the viewport stays where it was, so
|
||
// the cells are drawn thousands of rows away and the grid
|
||
// looks empty until the user scrolls to find them.
|
||
function seek() {
|
||
self.viewport-y = -min(
|
||
max(0px, self.viewport-height - self.height),
|
||
floor(root.scroll-to / max(1, root.columns))
|
||
* (root.cell-size + Theme.gap));
|
||
}
|
||
|
||
property <int> token: root.scroll-token;
|
||
changed token => { self.seek(); }
|
||
|
||
// Keep the keyboard cursor in view, moving as little as will
|
||
// do it.
|
||
//
|
||
// Deliberately *not* `seek()`: that puts the requested row at
|
||
// the top, which is right for a scrub — the user asked to go
|
||
// to a date and expects to arrive there — and wrong for an
|
||
// arrow key, where the grid jumping a row upward on every
|
||
// press makes the row impossible to read. So a cursor already
|
||
// on screen moves nothing at all, and one that has just left
|
||
// brings in exactly its own row.
|
||
property <length> pitch: root.cell-size + Theme.gap;
|
||
property <int> cursor-row: floor(root.cursor / max(1, root.columns));
|
||
changed cursor-row => { self.reveal(); }
|
||
|
||
function reveal() {
|
||
if (root.cursor < 0) {
|
||
return;
|
||
}
|
||
let top = Theme.gap + self.cursor-row * self.pitch;
|
||
let shown = -self.viewport-y;
|
||
let bottom = max(0px, self.viewport-height - self.height);
|
||
if (top < shown) {
|
||
self.viewport-y = -min(bottom, top);
|
||
} else if (top + self.pitch > shown + self.height) {
|
||
self.viewport-y = -min(bottom, top + self.pitch - self.height);
|
||
}
|
||
}
|
||
|
||
// Also on creation, which is what returning from the develop
|
||
// view needs. `show-library` gates an `if`, so the grid is built
|
||
// anew and `token` is *initialised* to the already-bumped value
|
||
// rather than changing to it — no `changed` handler fires, and
|
||
// without this the restored position would be dropped and the
|
||
// view would sit at the top.
|
||
init => { self.seek(); }
|
||
|
||
// Sized to the **whole library**, not the loaded window. The
|
||
// scrollbar has to represent 23,971 images or there is no way to
|
||
// reach image 20,000 — dragging it must be a real address, and the
|
||
// window is swapped underneath to match.
|
||
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap;
|
||
|
||
// TRACES: FR-CAT-7
|
||
// Stay inside the content when the content shrinks.
|
||
//
|
||
// Deleting photographs makes the library shorter, and the
|
||
// viewport is sized to the *whole* library — so a view that was
|
||
// scrolled near the end is suddenly scrolled past it. Slint
|
||
// does not pull a Flickable back on its own, so the grid went
|
||
// blank: the cells were still there, above a viewport looking
|
||
// at empty space below them.
|
||
//
|
||
// Worse than blank, it also *moved*. Cells are drawn at
|
||
// `(i + offset) / columns`, and a delete re-clamps `offset`
|
||
// downward to keep the window full — so the same `viewport-y`
|
||
// now points at a different part of the library, and the grid
|
||
// appeared to jump somewhere arbitrary. Re-seeking below is the
|
||
// other half of this; this half stops the blank.
|
||
changed viewport-height => {
|
||
if (-self.viewport-y > max(0px, self.viewport-height - self.height)) {
|
||
self.viewport-y = -max(0px, self.viewport-height - self.height);
|
||
}
|
||
}
|
||
|
||
// Report the first fully-scrolled-past row so Rust can move the
|
||
// window. Derived rather than eventful: Slint has no scroll
|
||
// callback, and a `changed` handler on a derived integer fires only
|
||
// when the row actually changes rather than on every pixel.
|
||
property <int> first-visible-row: max(0,
|
||
floor((-self.viewport-y - Theme.gap) / (root.cell-size + Theme.gap)));
|
||
changed first-visible-row => {
|
||
root.scrolled(self.first-visible-row * root.columns);
|
||
}
|
||
|
||
// Month headings, drawn over the grid at the row where each
|
||
// period begins. A separate pass rather than part of the cell,
|
||
// because the heading spans the full width and a cell does not.
|
||
for cell[i] in root.cells: Text {
|
||
x: Theme.gap;
|
||
// Sits in the gap above its row, so it labels the row
|
||
// rather than displacing it.
|
||
y: Theme.gap
|
||
+ floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap)
|
||
- 15px;
|
||
width: parent.width - 2 * Theme.gap;
|
||
text: cell.period-heading;
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-sm;
|
||
font-weight: 700;
|
||
visible: cell.period-heading != "";
|
||
}
|
||
|
||
for cell[i] in root.cells: DragArea {
|
||
// Cells are positioned at their **absolute** place in the
|
||
// library, not their index in the loaded window: the window
|
||
// starts at `offset`, so a cell drawn at window-index 0 belongs
|
||
// wherever `offset` sits in the full grid.
|
||
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap);
|
||
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap);
|
||
width: root.cell-size;
|
||
height: root.cell-size;
|
||
|
||
// Copy, not move: dropping into a collection files the
|
||
// photograph there without taking it out of anywhere else. That
|
||
// is what a join table means, and it is why the modifier-free
|
||
// gesture must not be `move`.
|
||
allow-copy: true;
|
||
data: root.drag-payload();
|
||
// What travels under the cursor is the photograph itself — and
|
||
// where several are being dragged, a stack of them. Composited
|
||
// in Rust (`drag_image`), because Slint takes a single bitmap
|
||
// here and cannot render a pile of thumbnails into one.
|
||
//
|
||
// Read on `dragging` rather than bound continuously: the
|
||
// composite costs a copy per thumbnail, and the grid must not
|
||
// pay it per cell per frame.
|
||
drag-image: root.drag-image;
|
||
|
||
changed dragging => {
|
||
if (self.dragging) {
|
||
root.drag-started(i);
|
||
}
|
||
}
|
||
drag-finished(action) => { root.drag-finished(); }
|
||
|
||
// 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: cell.selected ? Theme.selected : Theme.surface;
|
||
border-radius: Theme.radius;
|
||
// Selection outranks hover: a selected cell must stay legible
|
||
// once the pointer has moved on to the collection it is being
|
||
// dragged toward.
|
||
border-width: cell.selected ? 2px : (cell-touch.has-hover ? 1px : 0px);
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
VerticalLayout {
|
||
padding: 6px;
|
||
spacing: 4px;
|
||
|
||
Rectangle {
|
||
vertical-stretch: 1;
|
||
background: Theme.ground;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
// Faded while lifted, so the grid reads as the place
|
||
// the photograph came *from* and the cursor as where
|
||
// it is now. `colorize` would flatten it to one
|
||
// tint, which loses the picture; dropping opacity
|
||
// toward the ground keeps it recognisable as a ghost
|
||
// of itself.
|
||
// A rejected frame is held back rather than
|
||
// hidden: the cull is reversible, and a photo
|
||
// that vanished on one keypress would make the
|
||
// gesture frightening to use. Dimming is the
|
||
// cue that reads at grid scale — the cross on
|
||
// the mark confirms it up close.
|
||
opacity: cell.lifted ? 0.25
|
||
: (cell.flag == 2 ? 0.4 : 1.0);
|
||
animate opacity { duration: 120ms; }
|
||
}
|
||
|
||
if !cell.has-thumb: Caption {
|
||
text: cell.unavailable ? "no preview" : "…";
|
||
horizontal-alignment: center;
|
||
}
|
||
|
||
// "Already filed, in this many collections." Without it
|
||
// there is no way to tell a filed photograph from an
|
||
// unfiled one, and the user re-files what is already
|
||
// in place.
|
||
if cell.collection-count > 0: Rectangle {
|
||
x: parent.width - self.width - 4px;
|
||
y: 4px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
background: Theme.selected;
|
||
opacity: 0.9;
|
||
|
||
Text {
|
||
text: cell.collection-count;
|
||
color: Theme.ink;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// Pick or reject, top left — the opposite corner
|
||
// from the collection badge so the two never
|
||
// collide on a cell that carries both.
|
||
FlagMark {
|
||
x: 4px;
|
||
y: 4px;
|
||
flag: cell.flag;
|
||
}
|
||
|
||
}
|
||
|
||
Label {
|
||
text: cell.name;
|
||
emphasised: cell.selected;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// The end a shift-click measures from. An inner ring rather
|
||
// than a colour of its own: it has to be legible against a
|
||
// thumbnail of any brightness and must not read as a second
|
||
// kind of selection, which a hue would.
|
||
Rectangle {
|
||
visible: cell.anchor;
|
||
x: 4px;
|
||
y: 4px;
|
||
width: parent.width - 8px;
|
||
height: parent.height - 8px;
|
||
background: transparent;
|
||
border-width: 1px;
|
||
border-color: Theme.selected-ring;
|
||
border-radius: Theme.radius-sm;
|
||
}
|
||
|
||
// Selection only. The drag is the enclosing `DragArea`'s
|
||
// business, and Slint keeps a click distinct from a drag for
|
||
// us — which is exactly the arbitration the hand-rolled version
|
||
// had to fake with a travel threshold.
|
||
cell-touch := TouchArea {
|
||
mouse-cursor: pointer;
|
||
|
||
// Selected on *press*, not on release: the drag that may
|
||
// follow reads the selection to build its payload, and by
|
||
// release the pointer is over the sidebar.
|
||
//
|
||
// In selection mode the press is reported as though ctrl
|
||
// were held. That is not a shortcut: toggling one cell
|
||
// while keeping the rest *is* what ctrl-click means, and
|
||
// giving touch its own policy would be a second copy of
|
||
// the rules in `collections_ui::apply_press` to keep in
|
||
// step with the first.
|
||
// --- which finger, and whether it finished --------
|
||
//
|
||
// A pinch begins as an ordinary press. When the second
|
||
// finger lands Slint closes the first one's gesture by
|
||
// synthesising a `Released` at its position — that is
|
||
// how a Flickable is made to let go of a scroll it had
|
||
// already claimed. A TouchArea cannot tell that release
|
||
// from a real one and fires `clicked`, so every attempt
|
||
// to pinch-zoom the grid opened whichever photograph
|
||
// the first finger happened to be resting on.
|
||
//
|
||
// The finger id is what separates them: the synthetic
|
||
// release carries the id of the finger that *arrived*,
|
||
// never the one that pressed. A mouse reports 0 for
|
||
// both, so the desktop path is unchanged.
|
||
//
|
||
// `clicked` fires before the `up` that follows it, so
|
||
// it can only raise a flag — the decision to open has
|
||
// to wait for the event that names the finger.
|
||
property <int> down-finger: -1;
|
||
property <bool> click-pending: false;
|
||
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
self.down-finger = ev.touch-finger-id;
|
||
self.click-pending = false;
|
||
// A finger, not a pointer — see `touched`.
|
||
if (ev.touch-finger-id != 0) {
|
||
root.touched = true;
|
||
}
|
||
// Not while the grid is being pinched, nor in
|
||
// the moment after: this is the finger left
|
||
// over from the gesture, handed back as a new
|
||
// press. See `pinching`.
|
||
if (!root.pinching) {
|
||
// 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,
|
||
// Finger id 0 is the mouse — the same
|
||
// convention the pinch arbitration a
|
||
// few lines up already relies on.
|
||
ev.touch-finger-id != 0,
|
||
);
|
||
// 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) {
|
||
root.cell-clicked(i);
|
||
}
|
||
self.click-pending = false;
|
||
root.cell-press-ended();
|
||
}
|
||
// `cancel` is the important ending: the Flickable
|
||
// takes the pointer as soon as the finger travels,
|
||
// so without this a scroll that began on a cell
|
||
// would come to rest as a long press and select it.
|
||
if (ev.kind == PointerEventKind.cancel) {
|
||
self.click-pending = false;
|
||
root.cell-press-ended();
|
||
}
|
||
}
|
||
|
||
clicked => { self.click-pending = true; }
|
||
// The far end of a range, in selection mode. Slint
|
||
// delivers `clicked` for the first tap as well, which
|
||
// is why the toggling is idempotent-by-union in Rust
|
||
// rather than this file trying to swallow one of them.
|
||
double-clicked => { root.cell-double-clicked(i); }
|
||
|
||
// --- the rating strip, INSIDE the cell's hit area ---
|
||
//
|
||
// **A child of `cell-touch`, not a sibling of it.** Two
|
||
// things have to be true at once here, and only this
|
||
// nesting gets both.
|
||
//
|
||
// A star must take the click without it also reaching
|
||
// `cell-clicked`, or every rating throws the user into
|
||
// develop. Children are hit-tested before the element
|
||
// they sit in, and a child that accepts ends the walk
|
||
// before the TouchArea's own handler runs — so the star
|
||
// wins, and it wins for the same reason a later sibling
|
||
// used to.
|
||
//
|
||
// And the strip must not vanish as the pointer arrives
|
||
// at it. As a *sibling* it did: hover is tracked per
|
||
// TouchArea, Slint sends `Exit` to whatever drops out of
|
||
// the hit path, and the strip taking the pointer dropped
|
||
// `cell-touch` out of it. `has-hover` went false, which
|
||
// took `show-empty` with it, which hid the very stars
|
||
// the pointer was travelling towards — on an unrated
|
||
// cell they disappeared, the click landed on the cell
|
||
// behind them, and the image opened. That is the whole
|
||
// of the "stars vanish when I click one" report.
|
||
//
|
||
// An *ancestor* stays in the path: it keeps its place on
|
||
// the item stack, gets no `Exit`, and during a grab it is
|
||
// handed only the filter and never the event. So hover
|
||
// holds for as long as the pointer is anywhere in the
|
||
// cell, stars included.
|
||
//
|
||
// It sits over the foot of the thumbnail rather than
|
||
// below it: the caption row is spoken for by the
|
||
// filename, and a third row would cost thumbnail height
|
||
// on every cell to show something that is usually empty.
|
||
StarStrip {
|
||
x: (parent.width - self.width) / 2;
|
||
// Clear of the caption, which is the cell's last row.
|
||
y: parent.height - self.height - 26px;
|
||
rating: cell.rating;
|
||
// Empty stars appear once the pointer is over the
|
||
// cell, so there is something to aim at without
|
||
// filling the grid with chrome. That trade only
|
||
// works where there is a pointer to hover with: on
|
||
// touch the strip stands open, because a control
|
||
// that appears under the finger is a control that
|
||
// appears too late to aim at. See `touched`.
|
||
show-empty: cell-touch.has-hover || root.touched;
|
||
can-trash: !root.viewing-trash;
|
||
rate(n) => { root.cell-rated(i, n); }
|
||
trash() => { root.cell-trashed(i); }
|
||
}
|
||
|
||
// The 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; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|