Colour labels could be read from a Lightroom sidecar and queried by the selector, but nothing drew one or set one, so the only labels a library held were ones another program had written. Every mark carries its label's initial on its colour — R, Y, G, B, P — so a label is read without telling red from green, which is what NFR-A11Y-3 asks of colour labels by name. A grid cell shows the mark before its filename. In the grid, 6, 7, 8 and 9 set red, yellow, green and blue as Lightroom's keys do, on the photograph under the pointer or on the selection by the rule the star keys follow; the same key again takes the label off, and over a mixed selection it sets it on all. The selection bar gains Label, which opens the six choices — each a mark and a name — and purple, which has no key, is there. In develop the top bar says "Label: Green" beside the mark, opens the same choices, and 6-9 label the open photograph. Each gesture is one catalog transaction, then the grid, the counts and both sidecars are written as a rating's are. The filter bar gains a chip per label, its mark and its name with a count, one at a time; the filter is one SQL term, travels in the place record, and "All" clears it.
5079 lines
255 KiB
Plaintext
5079 lines
255 KiB
Plaintext
// The library grid: what a scanned library looks like.
|
||
//
|
||
// Cells come from Rust as a windowed model, never the whole catalog — a 17k
|
||
// image library must not become 17k live elements (FR-CAT-4).
|
||
//
|
||
// A cell with no thumbnail yet shows why rather than an empty box. On a remote
|
||
// library a thumbnail is a network round trip, so "nothing there", "not
|
||
// fetched yet", and "no preview in this file" are three different states and
|
||
// must not look identical (FR-NC-6c).
|
||
|
||
import { Theme } from "theme.slint";
|
||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint";
|
||
// The filing sheet lists the same rows the sidebar draws, from the same model:
|
||
// two lists of collections that could disagree about what exists is one list
|
||
// too many.
|
||
import { CollectionRow } from "collections.slint";
|
||
import { GestureSheet, GestureRow } from "gestures.slint";
|
||
import { Labels, LabelMark, LabelPicker } from "labels.slint";
|
||
|
||
// TRACES: FR-CAT-5
|
||
// One keyword in the keywording sheet, already answered against the selection.
|
||
//
|
||
// The three-way `coverage` is the whole reason this is a struct rather than a
|
||
// list of strings. Applying a word to forty photographs where thirty already
|
||
// carry it must not look like applying it to forty that carry none, and
|
||
// removing one that only some of them carry must not silently claim to have
|
||
// taken it off all forty. Rust computes it, because only Rust knows how big the
|
||
// selection is and how many of it each word covers.
|
||
export struct KeywordRow {
|
||
// Row id in `keyword_terms`, or 0 for a word an image carries that the
|
||
// vocabulary has no identity for yet. The sheet acts on `name`, never on
|
||
// this, so a 0 costs nothing — it is here so a future rename gesture has
|
||
// something to name.
|
||
id: int,
|
||
name: string,
|
||
// 0 none of the selection, 1 some of it, 2 all of it.
|
||
coverage: int,
|
||
// How many of the selected photographs carry it, for the "3 of 12" that
|
||
// makes `coverage: 1` a number rather than a shrug.
|
||
selected-count: int,
|
||
// How many photographs in the whole library carry it. Lets a word in
|
||
// regular use be told from one typed once by mistake.
|
||
image-count: int,
|
||
}
|
||
|
||
// One bar of the capture-time histogram.
|
||
export struct TimelineBar {
|
||
// 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet
|
||
// day stays visible beside a wedding.
|
||
height: float,
|
||
// Bucket start, Unix seconds. What a click on this bar scrubs to.
|
||
start: int,
|
||
count: int,
|
||
label: string,
|
||
// "2024", "Mar", or empty. Non-empty only where this bucket begins a new
|
||
// year or month, so the axis is labelled at boundaries rather than on
|
||
// every bar. Chosen in Rust, which knows the granularity.
|
||
period-label: string,
|
||
}
|
||
|
||
// The capture-time sidebar: the shape of the library over time, and the
|
||
// primary way of moving through it.
|
||
//
|
||
// **Vertical, and a sidebar rather than a strip**, because a scroll position is
|
||
// a relative quantity and a date is an absolute one. The grid's scrollbar says
|
||
// how far through you are; this says *when* you are.
|
||
//
|
||
// Three gestures, in the darktable idiom:
|
||
//
|
||
// - **click or drag** — scrub the grid to that instant
|
||
// - **drag with the middle button, or shift-drag** — pan the visible span
|
||
// - **wheel** — zoom, which changes the bucket size Rust picks
|
||
// - **drag either end of the range band** — narrow the grid to a period
|
||
//
|
||
// # One hit area, not one per bar
|
||
//
|
||
// An earlier version gave every bar its own `TouchArea`. With a few hundred
|
||
// buckets that is a few hundred overlapping hit regions, and a drag is
|
||
// delivered to whichever bar the press *started* on rather than the one under
|
||
// the cursor — so scrubbing jumped and stuttered. There is exactly one
|
||
// `TouchArea` here and the bucket is computed from the pointer's position.
|
||
export component Timeline inherits Rectangle {
|
||
in property <[TimelineBar]> bars;
|
||
in property <string> range-label;
|
||
/// The bucket the grid is currently showing, highlighted so the position is
|
||
/// visible when the grid is moved by scrolling instead.
|
||
in property <int> current-start: 0;
|
||
/// Where the grid sits along the visible span, 0..1, supplied by Rust.
|
||
///
|
||
/// **A fraction, not a bucket index.** The marker has to be placed by the
|
||
/// same quantity a click produces: `fraction-at` maps a y to a fraction of
|
||
/// the track and Rust interpolates an instant from it, so positioning the
|
||
/// marker from a bar index instead put it wherever that bucket's *slot*
|
||
/// happened to fall — never under the pointer. Negative means "not
|
||
/// anchored".
|
||
in property <float> current-fraction: -1;
|
||
/// Whether the marker has a position to report. False only before the first
|
||
/// axis has been built — Rust seeds it from wherever the grid sits, so a
|
||
/// launch draws the marker where the photographs on screen are rather than
|
||
/// leaving it dimmed at mid-track waiting for a scroll.
|
||
in property <bool> anchored: false;
|
||
|
||
/// Scrub to a fraction along the visible span. Rust turns it into an
|
||
/// instant, interpolating within a bucket rather than snapping to its edge.
|
||
callback scrub-to(float);
|
||
callback pan(float);
|
||
callback zoom(int);
|
||
/// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches
|
||
/// them together. Continuous, unlike the wheel's discrete steps.
|
||
callback pinch(float);
|
||
|
||
/// TRACES: FR-CAT-6
|
||
/// The chosen date range, as fractions of the drawn span — the same
|
||
/// coordinates `scrub-to` reports and `current-fraction` is drawn in, so
|
||
/// the band, the marker and a click cannot disagree about where a date
|
||
/// sits. Negative in both when no range is set.
|
||
in property <float> range-from: -1;
|
||
in property <float> range-to: -1;
|
||
/// An end of the range was dragged, and where both ends now are.
|
||
///
|
||
/// Sent on release rather than continuously: each one of these re-runs the
|
||
/// grid's query and reloads its window, and doing that per frame of a drag
|
||
/// is how a filter turns into a stutter. The band tracks the finger
|
||
/// meanwhile — that is what `live-from` and `live-to` are for.
|
||
callback range-changed(float, float);
|
||
|
||
width: 96px;
|
||
background: Theme.surface;
|
||
|
||
property <int> hovered: -1;
|
||
property <length> track-top: 22px;
|
||
property <length> track-height: max(1px, self.height - self.track-top - 6px);
|
||
property <length> slot: root.track-height / max(1, root.bars.length);
|
||
|
||
property <bool> has-range: root.range-from >= 0 && root.range-to >= 0;
|
||
/// Which end is under the finger: 0 neither, 1 the early end, 2 the late.
|
||
property <int> grabbed: 0;
|
||
/// The ends as the drag has them, before Rust has been told. Drawing from
|
||
/// these is what lets the band follow the finger without the grid being
|
||
/// re-queried on every frame.
|
||
property <float> live-from;
|
||
property <float> live-to;
|
||
property <float> shown-from: root.grabbed > 0 ? root.live-from : root.range-from;
|
||
property <float> shown-to: root.grabbed > 0 ? root.live-to : root.range-to;
|
||
/// How near a press has to land to take hold of an end, as a fraction of
|
||
/// the track. Sized for a finger rather than a pointer: this control
|
||
/// exists because on a phone the range could only be typed (FR-UI-2).
|
||
property <float> grab: 22px / root.track-height;
|
||
|
||
/// Bucket index under a y coordinate, clamped to the ends so a drag that
|
||
/// leaves the widget still scrubs to the nearest bucket rather than
|
||
/// stopping dead.
|
||
function bucket-at(y: length) -> int {
|
||
return clamp(floor((y - root.track-top) / max(1px, root.slot)),
|
||
0, max(0, root.bars.length - 1));
|
||
}
|
||
|
||
/// Position along the axis as a fraction, 0 at the first bucket's start and
|
||
/// 1 at the last one's end.
|
||
///
|
||
/// **Fractional rather than a bucket index.** Snapping to whole buckets
|
||
/// makes a slow drag feel dead — the pointer moves and nothing happens
|
||
/// until it crosses a boundary, then the grid jumps a whole month. Rust
|
||
/// interpolates an instant from this, so the grid tracks the finger.
|
||
function fraction-at(y: length) -> float {
|
||
return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0);
|
||
}
|
||
|
||
/// Where a fraction sits on the track. The inverse of `fraction-at`, and
|
||
/// it has to stay so: the band is drawn with this and dragged with that.
|
||
function y-at(f: float) -> length {
|
||
return root.track-top + clamp(f, 0.0, 1.0) * root.track-height;
|
||
}
|
||
|
||
/// Which end of the range a press at `y` takes hold of, 0 for neither.
|
||
///
|
||
/// Compared in fractions rather than pixels because the ends are held as
|
||
/// fractions — converting them to a y here and back to a fraction on the
|
||
/// drag would be a second copy of the same arithmetic to keep in step.
|
||
function handle-at(y: length) -> int {
|
||
if (!root.has-range) {
|
||
return 0;
|
||
}
|
||
if (min(abs(root.fraction-at(y) - root.shown-from),
|
||
abs(root.fraction-at(y) - root.shown-to)) > root.grab) {
|
||
return 0;
|
||
}
|
||
// A range narrowed to a day draws one line, not two. Taking the
|
||
// nearer end there would always answer "the early one" and leave the
|
||
// late end impossible to pull back out; which side of the line the
|
||
// press landed on is what the user meant by it.
|
||
if (abs(root.shown-from - root.shown-to) < 0.001) {
|
||
return root.fraction-at(y) > root.shown-from ? 2 : 1;
|
||
}
|
||
return abs(root.fraction-at(y) - root.shown-from)
|
||
< abs(root.fraction-at(y) - root.shown-to) ? 1 : 2;
|
||
}
|
||
|
||
/// Move the held end to `y`.
|
||
///
|
||
/// The ends are allowed to cross. Dragging one past the other is a normal
|
||
/// way to say "no, from *here* instead", and Rust puts the pair back in
|
||
/// order — stopping the finger dead at the other end would make a range
|
||
/// that has been narrowed too far harder to correct than to start again.
|
||
function drag-end(y: length) {
|
||
if (root.grabbed == 1) {
|
||
root.live-from = root.fraction-at(y);
|
||
} else {
|
||
root.live-to = root.fraction-at(y);
|
||
}
|
||
}
|
||
|
||
// Header: the hovered bucket, else the whole span.
|
||
Caption {
|
||
x: 8px;
|
||
y: 4px;
|
||
width: parent.width - 16px;
|
||
text: root.hovered >= 0 && root.hovered < root.bars.length
|
||
? root.bars[root.hovered].label + " · " + root.bars[root.hovered].count
|
||
: root.range-label;
|
||
emphasised: root.hovered >= 0;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The bars. Purely visual — every gesture is handled by the single
|
||
// TouchArea below, which sits above them.
|
||
for bar[i] in root.bars: Rectangle {
|
||
// Bars grow from the *left* edge, so the axis reads like a timeline
|
||
// turned on its side and the labels have room on the right.
|
||
x: 0;
|
||
y: root.track-top + i * root.slot;
|
||
width: max(1px, (parent.width - 34px) * bar.height);
|
||
height: max(1px, root.slot - 1px);
|
||
background: bar.start == root.current-start
|
||
? Theme.active
|
||
: (root.hovered == i ? Theme.ink-dim : Theme.ink-faint);
|
||
}
|
||
|
||
// Year and month labels down the right-hand edge.
|
||
//
|
||
// Drawn only where a bar *starts* a new period, so a month-bucketed view
|
||
// labels each January rather than repeating the year on every bar. The
|
||
// label text itself is chosen in Rust, which knows the granularity; an
|
||
// empty string means "no boundary here".
|
||
for bar[i] in root.bars: Text {
|
||
x: parent.width - 32px;
|
||
y: root.track-top + i * root.slot - 5px;
|
||
width: 30px;
|
||
text: bar.period-label;
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
visible: bar.period-label != "";
|
||
}
|
||
|
||
// Where the grid currently sits. Rests at the midpoint until the user has
|
||
// actually chosen a position.
|
||
Rectangle {
|
||
x: 0;
|
||
width: parent.width;
|
||
height: 1px;
|
||
background: Theme.active;
|
||
opacity: root.anchored ? 1.0 : 0.35;
|
||
y: root.anchored && root.current-fraction >= 0
|
||
? root.y-at(root.current-fraction)
|
||
: root.track-top + root.track-height / 2;
|
||
}
|
||
|
||
// --- the chosen date range ------------------------------------------
|
||
//
|
||
// Drawn *on* the axis, and dragged there. The two typed date fields under
|
||
// the filter chips were the only way to state a range, and on a phone they
|
||
// were barely a way at all: `YYYY-MM-DD` keyed into a 108px field behind a
|
||
// soft keyboard, to name a day that is already drawn on the axis a thumb
|
||
// away. The other route — zoom the axis to a period, then "limit to range"
|
||
// — needs a wheel to zoom and a middle button to pan, and a touch screen
|
||
// has neither.
|
||
//
|
||
// The axis keeps its full extent while a range is on (see
|
||
// `refresh_timeline`, and `Filter::without_date_range`). It has to: the
|
||
// bars outside the band are what the range is widened back *into*, and an
|
||
// axis that redrew itself to the band would move the ground under the very
|
||
// handles doing the narrowing.
|
||
if root.has-range: Rectangle {
|
||
// Outside the range, held back rather than hidden — those bars are
|
||
// still the shape of the library, and still where the band is going
|
||
// next. Starts at the track, so the header line stays legible.
|
||
Rectangle {
|
||
x: 0;
|
||
y: root.track-top;
|
||
width: parent.width;
|
||
height: max(0px, root.y-at(min(root.shown-from, root.shown-to)) - root.track-top);
|
||
background: Theme.surface;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
Rectangle {
|
||
x: 0;
|
||
y: root.y-at(max(root.shown-from, root.shown-to));
|
||
width: parent.width;
|
||
height: max(0px, parent.height - self.y);
|
||
background: Theme.surface;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
// The ends themselves: a line across the axis with a grip at the left,
|
||
// where the bars start. The right-hand strip carries the year and
|
||
// month labels, and a grip there would sit on top of them.
|
||
for edge[i] in [0, 1]: Rectangle {
|
||
x: 0;
|
||
y: root.y-at(i == 0 ? root.shown-from : root.shown-to) - 1px;
|
||
width: parent.width;
|
||
height: 2px;
|
||
background: Theme.active;
|
||
|
||
Rectangle {
|
||
x: 2px;
|
||
y: -5px;
|
||
width: 18px;
|
||
height: 12px;
|
||
border-radius: 3px;
|
||
background: Theme.active;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The single hit area. Everything above is inert.
|
||
// Two-finger pinch, for tablet. There is no wheel there, so without this
|
||
// the axis could only be zoomed by a control a finger cannot reach.
|
||
//
|
||
// `scale` is cumulative from 1.0 for the whole gesture, so the delta since
|
||
// the last update is what maps onto a zoom step — otherwise a slow spread
|
||
// would apply its total repeatedly and shoot straight to full zoom.
|
||
pinch := ScaleRotateGestureHandler {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => { self.last-scale = 1.0; }
|
||
updated => {
|
||
root.pinch(self.scale / max(0.01, self.last-scale));
|
||
self.last-scale = self.scale;
|
||
}
|
||
ended => { self.last-scale = 1.0; }
|
||
cancelled => { self.last-scale = 1.0; }
|
||
}
|
||
|
||
touch := TouchArea {
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: pointer;
|
||
|
||
property <length> press-y;
|
||
property <bool> panning;
|
||
|
||
moved => {
|
||
if (root.grabbed > 0) {
|
||
// The band follows the finger. The grid hears about it on
|
||
// release — see `range-changed`.
|
||
root.drag-end(self.mouse-y);
|
||
} else if (self.panning) {
|
||
// Fractional, so a slow drag moves the view continuously
|
||
// rather than sitting still until it crosses a bucket edge.
|
||
root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height));
|
||
self.press-y = self.mouse-y;
|
||
} else if (self.pressed && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
root.hovered = root.bars.length > 0 ? root.bucket-at(self.mouse-y) : -1;
|
||
}
|
||
|
||
pointer-event(e) => {
|
||
if (e.kind == PointerEventKind.down) {
|
||
self.press-y = self.mouse-y;
|
||
// Middle button pans. Shift is not consulted here: the
|
||
// modifier state belongs to the key handler, not the pointer
|
||
// event, and a middle-drag is the unambiguous gesture.
|
||
self.panning = e.button == PointerEventButton.middle;
|
||
// An end of the range first, if the press landed on one: a
|
||
// press within a finger's width of a handle means the handle,
|
||
// not the scrub it would otherwise have been.
|
||
root.grabbed = self.panning ? 0 : root.handle-at(self.mouse-y);
|
||
if (root.grabbed > 0) {
|
||
root.live-from = root.range-from;
|
||
root.live-to = root.range-to;
|
||
} else if (!self.panning && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
}
|
||
if (e.kind == PointerEventKind.up) {
|
||
self.panning = false;
|
||
if (root.grabbed > 0) {
|
||
root.grabbed = 0;
|
||
root.range-changed(root.live-from, root.live-to);
|
||
}
|
||
}
|
||
// A cancelled gesture — the system taking the touch for a
|
||
// back-swipe, most often — is not a range the user stated. The
|
||
// band goes back to where Rust still has it.
|
||
if (e.kind == PointerEventKind.cancel) {
|
||
self.panning = false;
|
||
root.grabbed = 0;
|
||
}
|
||
}
|
||
|
||
scroll-event(e) => {
|
||
// Wheel zooms rather than scrolls: the sidebar is an axis, not a
|
||
// list, and a scroll gesture over it means "show me more or less
|
||
// time" rather than "move down".
|
||
root.zoom(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
|
||
changed has-hover => {
|
||
if (!self.has-hover) { root.hovered = -1; }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One person on the filter bar, in either of the two roles it plays there.
|
||
///
|
||
/// The chips for who the grid is *currently* narrowed to, and the roster in the
|
||
/// tray the user picks from, are the same thing drawn twice — same id, same
|
||
/// wording — so they are one struct. `faces` and `picked` are the tray's half
|
||
/// and are simply not read by the narrowed-to chips.
|
||
export struct PersonChip {
|
||
id: int,
|
||
name: string,
|
||
/// How many faces the library holds of them. `-1` where it was not asked
|
||
/// for, which `FilterChip` draws as no number at all rather than as zero.
|
||
faces: int,
|
||
/// Already one of the people the grid is narrowed to.
|
||
picked: bool,
|
||
}
|
||
|
||
export struct LibraryCell {
|
||
name: string,
|
||
// Non-empty on the first cell of a new month, e.g. "August 2026". The grid
|
||
// is ordered by capture time, so these are the only place the date is
|
||
// legible without consulting the sidebar — a wall of thumbnails otherwise
|
||
// gives no sense of when you are.
|
||
period-heading: string,
|
||
// Empty until a fetch lands. `has-thumb` disambiguates, because Slint
|
||
// cannot test an image against null.
|
||
thumbnail: image,
|
||
has-thumb: bool,
|
||
// A fetch that completed with no usable preview. Distinct from pending.
|
||
unavailable: bool,
|
||
// Part of the current selection. Selection is what a drag carries, so this
|
||
// has to be per-cell state rather than a single "current" index.
|
||
selected: bool,
|
||
// How many collections this image belongs to. An image can be in many at
|
||
// once, and without a cue the grid gives no hint that a photograph has
|
||
// already been filed — the user re-files it, or hunts for where it went.
|
||
collection-count: int,
|
||
// This cell is one of the images currently being dragged. It reads as
|
||
// *lifted out*: desaturated and shrunk in place, so the grid shows where
|
||
// the photographs came from while the cursor shows them in full colour.
|
||
lifted: bool,
|
||
// Stars, 0..5. Zero is *unrated* — a state of its own, not a low score,
|
||
// and what "filter to unjudged" selects (FR-CULL-4).
|
||
rating: int,
|
||
// 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a
|
||
// four-star frame is a normal thing to do mid-cull.
|
||
flag: int,
|
||
// TRACES: FR-CAT-5
|
||
// The colour label: 0 none, 1..5 red, yellow, green, blue, purple —
|
||
// `versions.label`'s own codes (see labels.slint).
|
||
label: int,
|
||
// Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it
|
||
// belongs to none, which is most of a library. A burst that is collapsed
|
||
// draws only its representative, so on that cell this is the count of what
|
||
// is hidden behind it — the reason it is shown at all.
|
||
burst-count: int,
|
||
// Whether the group is currently open. Drawn differently rather than
|
||
// hidden: a burst the user has expanded is the one thing on screen that
|
||
// needs a way back, and a control that disappears once used is a control
|
||
// nobody finds twice.
|
||
burst-expanded: bool,
|
||
// Whether this is the frame the group folds up to — the earliest of them
|
||
// until the user says otherwise. Read only while the group is open: folded,
|
||
// the one cell on screen is the representative by construction, and a mark
|
||
// saying so would be telling the user what they can already see.
|
||
burst-representative: bool,
|
||
}
|
||
|
||
// TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5
|
||
// The library grid's own state: the loaded window of cells, the selection,
|
||
// the timeline, the filter bar, ratings and flags, keywording, drag and drop,
|
||
// and the routes into and out of the screen.
|
||
//
|
||
// A global rather than properties and callbacks on the window root — see
|
||
// `session.slint` for why the develop panels stopped taking their wiring
|
||
// through the window root, and `collections.slint` for the sidebar's own
|
||
// global; the same reasoning applies here. What is shared with the sidebar
|
||
// (the collection the grid is scoped to, whether the sidebar is shown) stays
|
||
// on the window, because `run()`'s panel-layout wiring and the sidebar's own
|
||
// Rust still reach it there.
|
||
export global Library {
|
||
in-out property <[LibraryCell]> library-cells;
|
||
in property <int> library-total: 0;
|
||
// Narrows the grid to images whose RAW is stored locally — the ones that
|
||
// can actually be opened while offline. Off by default: the catalog is the
|
||
// library, and hiding most of it the moment a connection drops would read
|
||
// as data loss rather than as a filter.
|
||
in-out property <bool> library-local-only: false;
|
||
in property <int> library-local-count: 0;
|
||
callback library-toggle-local-only();
|
||
/// TRACES: FR-CAT-5
|
||
callback library-clear-selection();
|
||
callback library-select-all();
|
||
/// TRACES: FR-CAT-7
|
||
/// Whether the grid is showing something with a manual order to change —
|
||
/// a single manual collection, not a set and not a saved filter.
|
||
in property <bool> library-reorderable: false;
|
||
/// Move the selection beside the photograph at this row of the loaded
|
||
/// window: after it where the drop landed on the cell's trailing half,
|
||
/// before it otherwise.
|
||
callback library-reorder-to(int, bool);
|
||
callback library-collection-from-selection(string);
|
||
in-out property <int> library-columns: 1;
|
||
in-out property <length> library-cell-size: 180px;
|
||
/// Whether this session is a touch one, which decides if the rating strip
|
||
/// waits to be hovered or stands open. Seeded from the platform by Rust
|
||
/// and latched by the grid on the first finger it sees.
|
||
in-out property <bool> library-touched: false;
|
||
callback library-zoom-cells(int);
|
||
/// A pinch step on the grid: the ratio since the last update.
|
||
callback library-pinch-cells(float);
|
||
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||
callback library-pinch-started();
|
||
in property <int> library-scroll-to: 0;
|
||
in property <int> library-scroll-token: 0;
|
||
/// Which row of the loaded window is the photograph currently open in
|
||
/// develop, so the roll can mark it. `-1` when it is not in the window.
|
||
in property <int> library-roll-current: -1;
|
||
/// Centre the roll on the open photograph the next time the strip settles.
|
||
///
|
||
/// Raised by Rust when a develop session begins — a photograph opened from
|
||
/// the grid — and cleared by the strip itself once honoured. Owned here for
|
||
/// the same reason `roll-open` is: the develop view is rebuilt whenever an
|
||
/// overlay screen covers it, and a request raised before that must survive
|
||
/// it. See `PhotoRoll::centre-request`.
|
||
in-out property <bool> library-roll-centre: false;
|
||
/// A photograph was chosen from the roll: the row within the loaded
|
||
/// window, which is what a cell click reports too.
|
||
callback library-roll-pick(int);
|
||
callback library-columns-changed();
|
||
callback library-scrolled(int);
|
||
/// How many cells the grid's viewport shows at once. Rust sizes the
|
||
/// loaded window from it — see `library_ui::window_move`.
|
||
callback library-viewport-cells(int);
|
||
callback library-cell-clicked(int);
|
||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||
/// File the grid's selection in a collection without dragging it there:
|
||
/// the target's id, and whether to take the images out of the collection
|
||
/// currently being shown.
|
||
callback library-file-in-collection(int, bool);
|
||
/// TRACES: FR-UI-2
|
||
/// Whether a tap in the grid selects rather than opens, and the button
|
||
/// that turns it on. The long press does the same thing without it.
|
||
in property <bool> library-select-mode: false;
|
||
callback library-toggle-select-mode();
|
||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||
/// The row a press has held long enough to pick up, or `-1`.
|
||
///
|
||
/// Set by the same hold that turns on selection mode and cleared by the
|
||
/// grid when the press ends — `in-out` because both ends write it. See
|
||
/// `held-row` in `library.slint` for what it draws and what it stops.
|
||
in-out property <int> library-held-row: -1;
|
||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||
callback library-cell-press-ended();
|
||
// --- drag and drop ---
|
||
//
|
||
// Slint's own `DragArea`/`DropArea` carry this: pointer capture, the
|
||
// click-versus-drag threshold, arbitration against the grid's Flickable,
|
||
// and the image drawn under the cursor. So there is no badge to position
|
||
// and no hover state to mirror here — only the payload, which Rust builds
|
||
// from the selection, and the count the header shows.
|
||
/// The drag payload, built by Rust from the current selection.
|
||
pure callback library-drag-payload() -> data-transfer;
|
||
/// The bitmap that travels under the cursor: one thumbnail, or a fanned
|
||
/// stack where several images are being dragged. Composited in Rust.
|
||
in property <image> library-drag-image;
|
||
/// A drag began on a cell, so an unselected one can be promoted before the
|
||
/// payload is read.
|
||
callback library-drag-started(int);
|
||
callback library-drag-finished();
|
||
in property <int> library-selected-count: 0;
|
||
|
||
callback library-cell-pressed(int, bool, bool);
|
||
callback library-remove-from-collection();
|
||
// --- where the selection is filed (FR-CAT-7) ---------------------------
|
||
//
|
||
// `library-remove-from-collection` can only ever mean the collection the
|
||
// grid is scoped to — it is the only one that button can name. Opens the
|
||
// membership sheet, whose state (`Collections.membership-open` and
|
||
// `.membership-rows`) is on the sidebar's own global — only the library
|
||
// side knows the selection has changed enough to be worth recomputing
|
||
// the sheet against.
|
||
callback library-open-membership();
|
||
// The keyboard cursor: where the arrows are in the library, as an image
|
||
// ordinal. Rust owns it — clamping it needs the library's length, and
|
||
// moving it may have to swap the loaded window underneath.
|
||
in property <int> library-cursor: -1;
|
||
callback library-move-cursor(int, bool);
|
||
callback library-open-cursor();
|
||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||
/// Keywording the grid's selection. The catalog has been searchable by
|
||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||
///
|
||
/// The vocabulary arrives already answered against the selection — each row
|
||
/// says how many of the selected photographs carry that word — because only
|
||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||
/// the selection as a second model that could disagree with the first.
|
||
in property <[KeywordRow]> library-keywords;
|
||
/// The sheet is opening: recompute the rows against the selection as it
|
||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||
/// every arrow key and the sheet is shut for almost all of them.
|
||
callback library-keywords-opened();
|
||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||
/// word typed into the field and a word tapped in the list are one path.
|
||
callback library-assign-keyword(string);
|
||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||
callback library-unassign-keyword(string);
|
||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) ---
|
||
//
|
||
// Stars and pick/reject, set from the grid and persisted to the catalog
|
||
// and the sidecar. Every image enters unrated, which is a state of its
|
||
// own rather than a zero score.
|
||
/// A star was clicked on a cell: row, then the rating 0..5.
|
||
callback library-cell-rated(int, int);
|
||
/// The trash target was clicked on one cell, by row.
|
||
callback library-cell-trashed(int);
|
||
/// The burst mark was clicked on one cell, by row (FR-CULL-5).
|
||
callback library-burst-toggled(int);
|
||
/// One frame of an open burst was named as the one the group folds to, by
|
||
/// row (FR-CULL-5).
|
||
callback library-burst-representative-chosen(int);
|
||
/// Move the grid selection to the trash — the `Delete` key.
|
||
callback library-trash-selection();
|
||
/// A judgement key was pressed. One of the first two arguments is -1,
|
||
/// saying which axis was *not* meant. The third is the ordinal of the
|
||
/// cell under the pointer, or -1: Rust applies the key to that photograph
|
||
/// alone unless it is part of the selection (darktable's rule).
|
||
callback library-judged(int, int, int);
|
||
/// TRACES: FR-CAT-5 | FR-UI-5
|
||
/// A label key (6-9) was pressed in the grid: the label code, then the
|
||
/// ordinal under the pointer or -1, which reaches the same photographs a
|
||
/// rating key would. The key toggles — see `dr_catalog::rating::
|
||
/// toggled_label`.
|
||
callback library-labelled(int, int);
|
||
/// TRACES: FR-CAT-5 | FR-UI-4
|
||
/// A label was chosen from the selection bar's picker, 0 for none. Sets
|
||
/// rather than toggles: the picker names the answer outright.
|
||
callback library-label-chosen(int);
|
||
/// TRACES: FR-CAT-5
|
||
/// One photograph by row, from develop: the code, and whether it toggles
|
||
/// (a key) or sets (the picker).
|
||
callback library-cell-labelled(int, int, bool);
|
||
/// TRACES: FR-CAT-6
|
||
/// The label the grid is narrowed to, 0 for none; and the images behind
|
||
/// each label, index 0 unlabelled, for the chips.
|
||
in property <int> library-filter-label: 0;
|
||
in-out property <[int]> library-label-counts;
|
||
callback library-filter-label-changed(int);
|
||
in property <int> library-filter-min-rating: 0;
|
||
/// TRACES: FR-UI-5
|
||
/// The top of a star range, 5 where there is none, and the F chord that
|
||
/// sets both ends at once.
|
||
in property <int> library-filter-max-rating: 5;
|
||
callback library-filter-rating-range(int, int);
|
||
in property <bool> library-filter-unjudged: false;
|
||
in property <int> library-filter-flag: 0;
|
||
/// Images per star count, index 0 unrated, for the filter chips.
|
||
in-out property <[int]> library-rating-counts;
|
||
callback library-filter-min-rating-changed(int);
|
||
callback library-filter-unjudged-changed(bool);
|
||
callback library-filter-flag-changed(int);
|
||
/// Whether the grid is narrowed to the timeline's visible span.
|
||
in-out property <bool> library-range-active: false;
|
||
callback library-toggle-date-range();
|
||
/// TRACES: FR-CAT-6
|
||
in property <string> library-range-from;
|
||
in property <string> library-range-to;
|
||
in property <bool> library-range-invalid: false;
|
||
callback library-range-edited(string, string);
|
||
/// The same range as a band on the capture-time axis, and the ends of it
|
||
/// dragged. Fractions of the span the axis is drawn over — Rust owns the
|
||
/// conversion both ways, so the band lands where the finger left it.
|
||
in property <float> library-range-from-fraction: -1;
|
||
in property <float> library-range-to-fraction: -1;
|
||
callback library-timeline-range-changed(float, float);
|
||
in property <string> library-root-label: "";
|
||
in-out property <[TimelineBar]> library-timeline;
|
||
in property <string> library-timeline-label: "";
|
||
in property <string> library-window-label: "";
|
||
in property <int> library-offset: 0;
|
||
|
||
in property <int> library-sweep-done: 0;
|
||
in property <int> library-sweep-total: 0;
|
||
|
||
in property <int> library-current-bucket: 0;
|
||
in property <float> library-current-fraction: -1;
|
||
in property <bool> library-timeline-anchored: false;
|
||
|
||
callback library-scrub-fraction(float);
|
||
callback library-timeline-pinch(float);
|
||
callback library-timeline-pan(float);
|
||
callback library-timeline-zoom(int);
|
||
/// Whose photographs the grid is narrowed to, one entry each.
|
||
in property <[PersonChip]> library-filter-people;
|
||
/// Whether those people are an intersection rather than a union.
|
||
in property <bool> library-filter-people-all: false;
|
||
callback library-filter-person-cleared(int);
|
||
callback library-filter-people-mode-toggled();
|
||
/// TRACES: FR-CULL-8a | FR-CULL-13
|
||
/// Whether the grid is narrowed to photographs with nobody blinking.
|
||
in property <bool> library-filter-eyes-open: false;
|
||
callback library-filter-eyes-open-toggled();
|
||
/// Everyone the library knows, for the filter bar's people tray.
|
||
/// TRACES: FR-UI-4
|
||
/// The gesture reference's rows, read from the generated table.
|
||
in property <[GestureRow]> library-gestures;
|
||
in property <[PersonChip]> library-people;
|
||
callback library-people-listed();
|
||
callback library-filter-person-toggled(int);
|
||
in property <bool> library-scanning: false;
|
||
/// The catalog on this device is being opened and checked, on a worker,
|
||
/// before the scan starts (`library_ui::open_catalog_soon`).
|
||
///
|
||
/// Separate from `library-scanning` because they are different answers to
|
||
/// the same question: this one is reading a file that is already here, and
|
||
/// that one is walking a tree over the network. Both are true at once for
|
||
/// the first moment of a launch, and only the more specific of them is
|
||
/// worth putting on an empty grid.
|
||
in property <bool> library-opening: false;
|
||
in property <string> library-status: "";
|
||
in property <string> library-error: "";
|
||
// --- offline mode (FR-CAT-9) ---
|
||
//
|
||
// The library keeps working from local data when the server cannot be
|
||
// reached: thumbnails come from the shards, and rating and sorting are
|
||
// catalog operations that never needed the network. What stops is opening
|
||
// an original that was never stored locally.
|
||
//
|
||
// `offline-reason` carries the transport's own message ("connection
|
||
// refused", "dns error") because it is usually specific enough to act on,
|
||
// where a bare "offline" leaves the user guessing whether it is their wifi
|
||
// or the server.
|
||
in property <bool> library-offline: false;
|
||
in property <string> library-offline-reason: "";
|
||
in property <string> library-offline-since: "";
|
||
callback library-retry-connection();
|
||
// --- pinning a collection offline (FR-NC-6a) ---
|
||
in property <bool> library-scope-pinned: false;
|
||
in property <int> library-pin-done: 0;
|
||
in property <int> library-pin-total: 0;
|
||
callback library-toggle-pin-scope();
|
||
in property <bool> library-syncing: false;
|
||
/// TRACES: FR-CAT-3 | FR-NC-3
|
||
/// The whole-library thumbnail pass, launched from the settings page.
|
||
/// `library-open` gates the button on there being a library to sweep —
|
||
/// the page is reachable from the launch screen too.
|
||
in property <bool> library-thumbnailing: false;
|
||
in property <bool> library-open: false;
|
||
callback library-thumbnail-all();
|
||
callback library-sync-now();
|
||
callback library-rescan();
|
||
/// Grid → launch screen, to pick a different folder or account.
|
||
callback library-change();
|
||
/// Merge the grid's selection: opens the page and starts the alignment.
|
||
callback library-merge-selection();
|
||
/// Whether a batch is running. The grid's export button becomes its cancel
|
||
/// button; there is nowhere else a long batch can be stopped from.
|
||
in property <bool> library-exporting: false;
|
||
/// Export the grid's selection in the background (FR-EXP-7).
|
||
callback library-export-selection();
|
||
/// TRACES: NFR-ARCH-3
|
||
callback library-cancel-export();
|
||
}
|
||
|
||
// The photo roll: the grid's loaded window along the foot of the develop view.
|
||
//
|
||
// # Why this exists
|
||
//
|
||
// Develop opens *one* photograph. `index` and `total` are pinned to "1 of 1"
|
||
// on the way in, because the grid hands over a path and nothing else, so the
|
||
// only way to reach the next frame was to go back to the library, find where
|
||
// you were, and tap again. That is fine once and intolerable through a set of
|
||
// forty — which is precisely the situation the develop view exists for.
|
||
//
|
||
// # The gesture
|
||
//
|
||
// A swipe up from the bottom edge brings it out, a swipe down puts it away:
|
||
// the sheet gesture, already in the hands of anyone who has used a phone.
|
||
// A gesture with no visible counterpart is a feature only its author knows
|
||
// about (FR-UI-4), so a handle is drawn at the edge and is a button in its own
|
||
// right — which is also what gives a pointer, with no swipe to make, a way in.
|
||
//
|
||
// The handler wraps the strip rather than sitting over or under it. That is
|
||
// what `SwipeGestureHandler` is built for: it delays a press the way a
|
||
// Flickable does, forwards it to the children if no swipe develops, and claims
|
||
// it once one does. A tap therefore reaches the thumbnail and a drag does not.
|
||
//
|
||
// It covers only the band along the bottom, never the whole canvas — above
|
||
// that band a drag belongs to the photograph, for panning and for the crop —
|
||
// and while the roll is away the band narrows to the handle's own `reach`,
|
||
// because a gesture handler consumes every press that lands in it. The note
|
||
// on `swipe` below says what that cost before it did.
|
||
export component PhotoRoll inherits Rectangle {
|
||
in property <[LibraryCell]> cells;
|
||
/// Which row of the loaded window is open, so it can be marked. `-1` when
|
||
/// the open photograph is not in the window at all, which is the honest
|
||
/// answer after a scrub — the roll shows where you are, and sometimes the
|
||
/// answer is "not here".
|
||
in property <int> current: -1;
|
||
in-out property <bool> open: false;
|
||
|
||
/// Centre the open photograph the next time the strip settles, instead of
|
||
/// merely bringing it into view.
|
||
///
|
||
/// **A one-shot request, cleared here rather than by Rust**, because the
|
||
/// only thing that knows the request has been honoured is the code that
|
||
/// honours it. Rust raises it when a photograph is opened *from the grid* —
|
||
/// the start of a develop session — and from then on every pick along the
|
||
/// roll uses `reveal()`, which moves as little as will show the mark. That
|
||
/// is the difference the user asked for: arriving at a photograph should
|
||
/// show what is either side of it, and stepping along from there should not
|
||
/// keep yanking the strip back to centre.
|
||
///
|
||
/// It has to be a flag the strip consumes rather than something recomputed
|
||
/// on creation, because the strip is created more often than a session
|
||
/// begins: leaving develop for Settings and coming back rebuilds it, and
|
||
/// re-centring then would undo a roll the user had scrolled by hand.
|
||
in-out property <bool> centre-request: false;
|
||
|
||
/// A thumbnail was chosen. The row within the loaded window, matching what
|
||
/// a cell click reports.
|
||
callback pick(int);
|
||
|
||
/// Both from the theme rather than from here, because the canvas
|
||
/// positions its floating controls against the band these two describe.
|
||
property <length> strip-height: Theme.roll-strip;
|
||
/// The band of canvas left grabbable when the roll is away. A thumb's
|
||
/// worth, and no more: it is taken off the bottom of the photograph.
|
||
property <length> reach: Theme.roll-reach;
|
||
property <length> thumb: 92px;
|
||
property <length> pad: 6px;
|
||
|
||
background: transparent;
|
||
|
||
swipe := SwipeGestureHandler {
|
||
width: 100%;
|
||
height: root.strip-height + root.reach;
|
||
// **The whole handler slides, and the strip sits still inside it.**
|
||
//
|
||
// A gesture handler is an input surface, not only a box to hang the
|
||
// strip on: while a press lands inside it that press is *taken* — it
|
||
// is delayed, then offered to the handler's own children and to
|
||
// nothing else, so no element behind it is ever asked. A band the full
|
||
// height of the strip is right while the strip is out and is a dead
|
||
// zone over the foot of the photograph while it is away, which is
|
||
// where the crop's bottom handles and the canvas's floating controls
|
||
// live.
|
||
//
|
||
// So the band goes where the roll goes. Closed, only `reach` of it is
|
||
// on screen — the handle, and the thumb's width of canvas that starts
|
||
// the swipe; the rest hangs below the window where nothing can press
|
||
// it. Open, it covers the strip, which is what lets a swipe down
|
||
// anywhere across the thumbnails put the roll away.
|
||
//
|
||
// This is also why the animation moved here from the strip: the strip
|
||
// is now fixed at `reach` within the handler, so the handler's `y` is
|
||
// the only thing left that travels.
|
||
y: root.open
|
||
? parent.height - self.height
|
||
: parent.height - root.reach;
|
||
animate y { duration: 180ms; easing: ease-out; }
|
||
|
||
handle-swipe-up: !root.open;
|
||
handle-swipe-down: root.open;
|
||
|
||
// Direction decides, not distance: the handler has already applied its
|
||
// own threshold by the time this fires, and re-testing the travel here
|
||
// would mean a swipe that qualified as a swipe still did nothing.
|
||
swiped => {
|
||
root.open = self.current-position.y < self.pressed-position.y;
|
||
}
|
||
|
||
// --- the strip ------------------------------------------------
|
||
//
|
||
// Slid out of view rather than removed. An `if` would have it appear
|
||
// fully formed at the bottom of the screen instead of arriving from
|
||
// the edge, and would leave nothing for the animation to act on.
|
||
// What travels is the handler around it; see the note on `swipe`.
|
||
strip := Rectangle {
|
||
width: 100%;
|
||
height: root.strip-height;
|
||
// Below the handle and no lower: the handler above carries both
|
||
// of them in and out.
|
||
y: root.reach;
|
||
background: Theme.surface;
|
||
clip: true;
|
||
|
||
Rectangle {
|
||
width: 100%;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
roll := Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width:
|
||
max(self.width,
|
||
root.cells.length * (root.thumb + root.pad) + root.pad);
|
||
|
||
// Bring the open photograph into view — on opening, and when
|
||
// the roll itself moves the selection along. Without it a pick
|
||
// near the end of the window scrolls back to the start on the
|
||
// next reveal, and the mark the strip exists to show is off
|
||
// the edge of it.
|
||
function reveal() {
|
||
if (root.current < 0) {
|
||
return;
|
||
}
|
||
let left = root.pad + root.current * (root.thumb + root.pad);
|
||
let shown = -self.viewport-x;
|
||
let right = max(0px, self.viewport-width - self.width);
|
||
if (left < shown) {
|
||
self.viewport-x = -min(right, left);
|
||
} else if (left + root.thumb > shown + self.width) {
|
||
self.viewport-x =
|
||
-min(right, left + root.thumb - self.width);
|
||
}
|
||
}
|
||
|
||
// Put the open photograph in the middle of the strip, with as
|
||
// much of the roll either side of it as there is.
|
||
//
|
||
// Clamped to the ends rather than centred unconditionally: the
|
||
// third photograph of a window cannot be centred without
|
||
// scrolling empty space in on the left, and a strip that starts
|
||
// with a gap reads as broken rather than as centred.
|
||
function centre() {
|
||
if (root.current < 0) {
|
||
return;
|
||
}
|
||
let left = root.pad + root.current * (root.thumb + root.pad);
|
||
let right = max(0px, self.viewport-width - self.width);
|
||
let want = left + root.thumb / 2 - self.width / 2;
|
||
self.viewport-x = -min(right, max(0px, want));
|
||
}
|
||
|
||
// Which of the two the strip does, and the only place the
|
||
// request is cleared.
|
||
//
|
||
// **Nothing happens before the strip has a width.** Centring
|
||
// divides by it, and `init` runs before layout — so honouring
|
||
// the request there would scroll to an arithmetic answer based
|
||
// on a width of zero *and* consume the flag, leaving the roll
|
||
// wrong with nothing left to correct it. Deferring instead is
|
||
// safe because the width arriving is itself a change, and
|
||
// `changed width` below settles again once it has.
|
||
function settle() {
|
||
if (self.width <= 0px) {
|
||
return;
|
||
}
|
||
if (root.centre-request) {
|
||
root.centre-request = false;
|
||
self.centre();
|
||
} else {
|
||
self.reveal();
|
||
}
|
||
}
|
||
|
||
property <int> mark: root.current;
|
||
changed mark => { self.settle(); }
|
||
property <bool> shown: root.open;
|
||
changed shown => {
|
||
if (self.shown) { self.settle(); }
|
||
}
|
||
|
||
// On creation, and again when the strip is first given a
|
||
// width. None of the four is redundant.
|
||
//
|
||
// The strip is rebuilt on every entry to develop, so `mark` and
|
||
// `shown` are *initialised* rather than changed and neither
|
||
// handler above fires — which is precisely the moment a session
|
||
// begins and the request is waiting to be honoured. A roll left
|
||
// open from the previous session would otherwise slide in
|
||
// showing whatever it showed last.
|
||
//
|
||
// `init` runs before layout, though, so it usually finds no
|
||
// width and defers; `changed width` is what actually honours the
|
||
// request a frame later. Both are kept because the order is not
|
||
// guaranteed, and a settle that has nothing to do is a no-op.
|
||
init => { self.settle(); }
|
||
changed width => { self.settle(); }
|
||
|
||
for cell[i] in root.cells: Rectangle {
|
||
x: root.pad + i * (root.thumb + root.pad);
|
||
y: root.pad;
|
||
width: root.thumb;
|
||
height: parent.height - 2 * root.pad;
|
||
background: Theme.ground;
|
||
border-radius: Theme.radius;
|
||
// The one that is open, marked the way the grid marks a
|
||
// selection so the two read as the same idea.
|
||
border-width: i == root.current ? 2px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
}
|
||
|
||
TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => { root.pick(i); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the handle -----------------------------------------------
|
||
//
|
||
// Rides on the strip's top edge, so it is in the same place relative to
|
||
// the roll whether it is in or out, and it is the affordance that stops
|
||
// the swipe being folklore.
|
||
Rectangle {
|
||
width: 84px;
|
||
height: root.reach;
|
||
x: (parent.width - self.width) / 2;
|
||
y: strip.y - self.height;
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
|
||
Rectangle {
|
||
width: 32px;
|
||
height: 3px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.ink-dim;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => { root.open = !root.open; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TRACES: NFR-A11Y-3
|
||
// A row of five stars, readable at a glance and clickable to set a rating.
|
||
//
|
||
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
|
||
// status by hue alone, and the palette is achromatic anyway — so a set star is
|
||
// a solid star at `active` and an unset one is an outline at `ink-faint`. The
|
||
// two differ in both shape and luminance, which survives greyscale and low
|
||
// vision alike.
|
||
//
|
||
// **Unrated draws nothing until hovered.** A grid of 120 cells each showing
|
||
// five empty stars is a wall of chrome competing with the photographs; a
|
||
// freshly scanned library would look like a spreadsheet. The strip appears on
|
||
// hover, so an unjudged cell is quiet and a judged one is legible from across
|
||
// the room.
|
||
export component StarStrip inherits Rectangle {
|
||
in property <int> rating: 0;
|
||
/// Show the empty stars even at zero — used while the pointer is over the
|
||
/// cell, so there is something to aim at.
|
||
in property <bool> show-empty: false;
|
||
/// Whether clicking sets a rating. False in a read-only context.
|
||
in property <bool> interactive: true;
|
||
/// Whether to offer the trash target at all. Hidden in the trash view,
|
||
/// where these photographs are already there — `plan_trash` would skip
|
||
/// them anyway, so the control would be inert, and an inert control that
|
||
/// looks live is worse than no control.
|
||
in property <bool> can-trash: true;
|
||
|
||
/// A star was clicked: the rating it stands for, 1..5.
|
||
callback rate(int);
|
||
/// The trash target was clicked. Named separately from [`rate`] rather
|
||
/// than being rating `-1`: this moves a file on the server, and a callback
|
||
/// that could mean either "set a rating" or "delete a photograph"
|
||
/// depending on the sign is one typo away from the wrong one.
|
||
callback trash();
|
||
|
||
// One star's drawn box. The *ink* stays small — five 44px stars would be
|
||
// 220px wide and swamp a 180px cell — while the hit area below grows to
|
||
// `Theme.touch-target`, which is the same split `Button` and `FormatCheck`
|
||
// make for the same FR-UI-3 reason.
|
||
property <length> star: 22px;
|
||
|
||
// Separation between the trash target and ★1.
|
||
//
|
||
// **This gap is load-bearing.** The star targets are 44px over a 22px
|
||
// cell, so they deliberately overlap and a near-miss lands one star out —
|
||
// harmless, same control, corrected by clicking again. That reasoning does
|
||
// not survive a neighbour that *moves a file*, so trash is held off the
|
||
// scale by a gap wider than the overhang it would otherwise share with
|
||
// ★1. A slip between them hits nothing at all, which is the correct
|
||
// outcome for an ambiguous press next to a destructive target.
|
||
property <length> trash-gap: 14px;
|
||
|
||
height: root.star;
|
||
// Sized to its content so the cell's layout does not reserve space for a
|
||
// strip that may be invisible.
|
||
width: 5 * root.star + (root.can-trash ? root.star + root.trash-gap : 0px);
|
||
// A ground behind the stars: they sit over a photograph that may be white
|
||
// at that point, and an outline star on a bright sky is invisible. Also
|
||
// makes the strip read as one control rather than five loose marks.
|
||
background: root.visible ? Theme.surface.with-alpha(0.75) : transparent;
|
||
border-radius: Theme.radius;
|
||
visible: root.rating > 0 || root.show-empty;
|
||
|
||
HorizontalLayout {
|
||
// Trash sits at the *left*, before the scale rather than beyond its
|
||
// top: reading left to right it is "remove this" and then a rising
|
||
// scale, which keeps ★5 at the end where a rating scale is expected
|
||
// to peak. Putting it past ★5 would make the strip read as a
|
||
// six-point scale whose last stop deletes.
|
||
Rectangle {
|
||
width: root.can-trash ? root.star : 0px;
|
||
height: root.star;
|
||
visible: root.can-trash;
|
||
|
||
Icon {
|
||
name: "trash";
|
||
// Reject and trash are the two destructive ends of this UI and
|
||
// share the palette's one hue, so the gesture reads the same
|
||
// in both places (NFR-A11Y-3: the shape carries it, not the
|
||
// colour).
|
||
ink: Theme.warn-ink;
|
||
size: 13px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
// Deliberately *not* grown to `touch-target`. Every other
|
||
// target here overhangs to meet FR-UI-3, but that requirement
|
||
// is about reaching a control with a thumb — it is not a
|
||
// reason to make a destructive one easier to hit by accident
|
||
// than the thing beside it. 22px plus the gap is a real target
|
||
// without reaching into ★1's band.
|
||
width: 100%;
|
||
height: 100%;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
clicked => { root.trash(); }
|
||
}
|
||
}
|
||
|
||
// The gap itself, inert — no TouchArea, so a press here does nothing
|
||
// rather than resolving to whichever neighbour is closer.
|
||
Rectangle { width: root.can-trash ? root.trash-gap : 0px; }
|
||
|
||
for n[i] in [1, 2, 3, 4, 5]: Rectangle {
|
||
width: root.star;
|
||
height: root.star;
|
||
|
||
Icon {
|
||
// Solid versus outline: the shape says it, not the colour.
|
||
name: root.rating >= n ? "star" : "star-outline";
|
||
ink: root.rating >= n ? Theme.active : Theme.ink-dim;
|
||
// Large enough to hit the difference between filled and empty
|
||
// at arm's length on a tablet; the two differ in fill, which
|
||
// needs more pixels to read than a difference in shape would.
|
||
size: 14px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
// Grown past the drawn star to meet FR-UI-3's 44pt minimum, and
|
||
// centred on it. The overhang overlaps its neighbours, so the
|
||
// *later* star wins the shared band — which is why this is
|
||
// acceptable here: adjacent targets belong to the same control and
|
||
// a near-miss sets a rating one star out, not something unrelated.
|
||
//
|
||
// Vertical overhang spills outside the strip onto the thumbnail,
|
||
// which carries no hit area of its own — the cell's TouchArea is
|
||
// below this in z-order.
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
width: max(parent.width, Theme.touch-target);
|
||
height: max(parent.height, Theme.touch-target);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
// Clicking the star already set clears the rating — the
|
||
// gesture every photo tool uses for "undo that", and without
|
||
// it the only way back to unrated is the keyboard.
|
||
clicked => { root.rate(root.rating == n ? 0 : n); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TRACES: NFR-A11Y-3
|
||
// The pick/reject mark.
|
||
//
|
||
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
||
// a tick and a cross are distinguishable without hue, and a reject reads as a
|
||
// reject in greyscale. A reject also dims its whole cell, which is the cue
|
||
// that carries at grid scale — the mark is confirmation, not the primary
|
||
// signal.
|
||
export component FlagMark inherits Rectangle {
|
||
in property <int> flag: 0;
|
||
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
visible: root.flag > 0;
|
||
background: Theme.surface;
|
||
opacity: 0.92;
|
||
|
||
Icon {
|
||
name: root.flag == 2 ? "cross" : "check";
|
||
// Reject earns the one hue in the palette: it is the destructive end
|
||
// of the axis and the thing a user must not mistake for a pick.
|
||
ink: root.flag == 2 ? Theme.warn-ink : Theme.active;
|
||
size: 9px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
}
|
||
|
||
// The header's action buttons, in one place so they can be drawn in two.
|
||
//
|
||
// A tablet in portrait is 768 logical pixels wide, and the header wants a
|
||
// title, a status line and these buttons. Laid out in one row they all shrink
|
||
// to their minimum and elide — "Change library" arrives as "Change li…" — and
|
||
// the row still overflows its own width. Slint's HorizontalLayout has no wrap
|
||
// and no overflow, so the row has to be told what to drop.
|
||
//
|
||
// Extracted rather than duplicated because the alternative is these buttons
|
||
// written twice with their visibility rules and callbacks copied, and the
|
||
// copy that gets forgotten is the one behind the disclosure nobody opens
|
||
// while testing.
|
||
//
|
||
// **Nothing in here depends on the selection.** Everything a selection can be
|
||
// done *to* lives on the bar at the foot of the grid, which is also where the
|
||
// selection is reported. Six buttons used to appear and disappear from the
|
||
// middle of this row as photographs were picked, shunting `Sync`, `Settings`
|
||
// and everything beside them several hundred pixels sideways at the exact
|
||
// moment a hand was already travelling toward one — and splitting "what can I
|
||
// do with these?" across two bars at opposite ends of the window. A row that
|
||
// only changes when the *library* changes can be aimed at from memory.
|
||
//
|
||
// Nor does anything in here refer to *one collection*. "Keep offline" and
|
||
// "Change library" are in the collections panel, under the tree whose
|
||
// selection decides what the first of them acts on and which the second
|
||
// replaces wholesale.
|
||
component HeaderActions inherits HorizontalLayout {
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Whether taps are selecting rather than opening. The non-gesture half of
|
||
/// touch multi-selection: the long press is the quick way in, and this is
|
||
/// the way that can be *found*.
|
||
in property <bool> select-mode: false;
|
||
in property <bool> scanning: false;
|
||
in property <bool> syncing: false;
|
||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||
/// is sized to its content.
|
||
in property <bool> centred: true;
|
||
in property <length> row-height: 44px;
|
||
|
||
callback toggle-select-mode();
|
||
callback sync-now();
|
||
/// TRACES: FR-CAT-10
|
||
/// Whether this platform can reach a card at all (`dr_plat::imports_supported`).
|
||
///
|
||
/// Hidden rather than disabled, unlike the buttons that come and go with a
|
||
/// scan: those are unavailable *now* and will be available in a moment,
|
||
/// where this one never will be on this device. A permanently disabled
|
||
/// control teaches the reader that the row lies.
|
||
in property <bool> can-import: false;
|
||
|
||
callback rescan();
|
||
callback open-import();
|
||
callback open-settings();
|
||
callback open-people();
|
||
/// TRACES: FR-UI-4
|
||
/// Open the gesture reference.
|
||
callback open-gestures();
|
||
|
||
spacing: Theme.gap;
|
||
|
||
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
|
||
// The way *into* selecting, and the only selection control left in this
|
||
// row — everything a selection can be done *to* is on the bar at the foot
|
||
// of the grid, beside the count that says what it would act on.
|
||
//
|
||
// It exists at all because the desktop's way in — ctrl-click, shift-click —
|
||
// has no touch equivalent, and the drag that files a photograph in a
|
||
// collection needs a *selection* before it can carry more than one. On a
|
||
// tablet the long press does this faster; a gesture with no visible
|
||
// counterpart is a feature only its author knows about (FR-UI-4).
|
||
//
|
||
// "Done" rather than "Selecting": the label should say what pressing it
|
||
// does, and the inverted fill already says which state we are in.
|
||
Button {
|
||
text: root.select-mode ? "Done" : "Select";
|
||
active: root.select-mode;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.toggle-select-mode(); }
|
||
}
|
||
|
||
Button {
|
||
// Shares the finished index so a second device inherits it rather
|
||
// than repeating hours of range fetches.
|
||
text: root.syncing ? "Syncing…" : "Sync";
|
||
enabled: !root.syncing && !root.scanning;
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.sync-now(); }
|
||
}
|
||
|
||
if !root.scanning: Button {
|
||
text: "Rescan";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.rescan(); }
|
||
}
|
||
|
||
// TRACES: FR-CAT-10
|
||
// Beside Rescan rather than beside Settings: both put photographs into the
|
||
// library, where Settings is about the application. Hidden during a scan
|
||
// for the same reason Rescan is — an import writes files the running scan
|
||
// would then half-see, and the two disagreeing about what is in a folder
|
||
// is a worse outcome than waiting.
|
||
if !root.scanning && root.can-import: Button {
|
||
text: "Import";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-import(); }
|
||
}
|
||
|
||
// A library action rather than a setting, so it sits with the others and
|
||
// ahead of Settings, which the comment below keeps last.
|
||
Button {
|
||
text: "Identity";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-people(); }
|
||
}
|
||
|
||
// TRACES: FR-UI-4
|
||
// What the gestures are, for the ones that have no button of their own.
|
||
// Beside Settings because it is about the application rather than about
|
||
// the photographs, and unconditional for the same reason Settings is: a
|
||
// reference you can only reach in some states is one you look for in the
|
||
// state where you needed it and do not find.
|
||
// "Help" rather than "Gestures": most of what it lists is now keys, and
|
||
// a reader looking for a shortcut does not think to look under gestures.
|
||
Button {
|
||
text: "Help";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-gestures(); }
|
||
}
|
||
|
||
// Last in the row, and unconditional. Nothing before it moves with the
|
||
// selection any more, so this is a fixed address rather than a control
|
||
// that drifts as photographs are picked.
|
||
Button {
|
||
text: "Settings";
|
||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||
clicked => { root.open-settings(); }
|
||
}
|
||
}
|
||
|
||
export component LibraryGrid inherits Rectangle {
|
||
/// TRACES: FR-UI-1
|
||
/// The layout class, from the window width rather than the device.
|
||
///
|
||
/// Compact moves the header's actions behind a disclosure; see
|
||
/// [`HeaderActions`]. Defaults to expanded so a caller that forgets to
|
||
/// pass it gets the desktop layout rather than a hidden toolbar.
|
||
in property <bool> expanded: true;
|
||
/// Whether the compact action row is showing. Local to the grid: it is a
|
||
/// disclosure, not a preference, and it should close itself the moment
|
||
/// the window is wide enough not to need it.
|
||
property <bool> actions-open: false;
|
||
changed expanded => {
|
||
if (root.expanded) { root.actions-open = false; }
|
||
}
|
||
|
||
in property <[LibraryCell]> cells;
|
||
in property <int> total: 0;
|
||
in property <bool> scanning: false;
|
||
/// The catalog on this device is being opened and checked before the scan
|
||
/// starts. Distinct from `scanning` for the reason the empty state below
|
||
/// gives: they are two different waits and only one of them is the network.
|
||
in property <bool> opening: false;
|
||
in property <string> scan-status: "";
|
||
in property <string> scan-error: "";
|
||
/// Which folder is being shown. Visible at all times: two similarly-named
|
||
/// folders are easy to confuse, and a scan of the wrong one looks
|
||
/// identical to a broken scan.
|
||
in property <string> root-label: "";
|
||
|
||
// --- capture-time scrubber ---
|
||
in property <[TimelineBar]> timeline;
|
||
in property <string> timeline-label: "";
|
||
/// Dates spanned by the cells currently shown.
|
||
in property <string> window-label: "";
|
||
in property <int> offset: 0;
|
||
/// Where the view should be, as an image ordinal. A scrub sets this; the
|
||
/// grid follows it.
|
||
///
|
||
/// Bumped by `scroll-token` rather than watched directly: scrubbing twice
|
||
/// to the same date must still move the view, and an unchanged property
|
||
/// fires no `changed` handler.
|
||
in property <int> scroll-to: 0;
|
||
in property <int> scroll-token: 0;
|
||
|
||
/// Which bucket the grid currently sits in, and how far along the visible
|
||
/// span that is. Rust supplies both: it owns the span, so only it can turn
|
||
/// an instant into the fraction that places the marker.
|
||
in property <int> current-bucket: 0;
|
||
in property <float> current-bucket-fraction: -1;
|
||
/// False until the user has moved the timeline themselves, so the marker
|
||
/// rests at the middle rather than implying a choice not yet made.
|
||
in property <bool> timeline-anchored: false;
|
||
|
||
/// A fraction along the visible span, not a bucket index — the timeline
|
||
/// interpolates so a slow drag tracks the finger rather than snapping.
|
||
callback scrub-fraction(float);
|
||
callback timeline-pan(float);
|
||
callback timeline-zoom(int);
|
||
/// Pinch ratio: above 1 spreads (zoom in), below 1 pinches (zoom out).
|
||
callback timeline-pinch(float);
|
||
/// Ctrl+wheel or pinch over the grid: resize the cells. A signed step,
|
||
/// not a size, so Rust owns the bounds.
|
||
callback zoom-cells(int);
|
||
/// A pinch step: the ratio since the last update, above 1 spreading. The
|
||
/// continuous counterpart of `zoom-cells`, which the wheel steps.
|
||
callback pinch-cells(float);
|
||
/// A pinch has begun, so the press that opened it was not a press.
|
||
callback pinch-started();
|
||
callback columns-changed(int);
|
||
callback sync-now();
|
||
/// The grid scrolled: the first visible image's ordinal in the library.
|
||
/// Rust answers by loading the window around that position.
|
||
callback scrolled(int);
|
||
/// How many cells the viewport can show at once changed — a resize, or a
|
||
/// column-count change.
|
||
///
|
||
/// The *screenful*, not the window to load. How many screenfuls are held
|
||
/// around it, and how close the view may come to an edge of them before
|
||
/// the window moves, are one decision and it is made in Rust — split
|
||
/// across the two languages they drifted apart, and the gap between them
|
||
/// was rows on screen that no loaded cell covered.
|
||
callback viewport-cells-changed(int);
|
||
|
||
// Thumbnail progress is no longer reported here: the batch belongs to a
|
||
// window of the grid rather than to anything the user asked for, and the
|
||
// shell's bar now carries it along with every other running job.
|
||
|
||
// Whole-library indexing, which runs for far longer than one window's
|
||
// thumbnails and is reported separately so the two do not fight over the
|
||
// same line.
|
||
in property <int> sweep-done: 0;
|
||
in property <int> sweep-total: 0;
|
||
/// Pushing shards and the catalog to the server.
|
||
in property <bool> syncing: false;
|
||
property <bool> sweeping: root.sweep-total > 0 && root.sweep-done < root.sweep-total;
|
||
|
||
// --- the header's one status line -------------------------------------
|
||
//
|
||
// Four readouts used to sit in the header beside the title: the image
|
||
// count, the selection count, the scan's status and where in the library
|
||
// the visible window sits. Each carried `horizontal-stretch: 1` — which is
|
||
// what actually lets a `Text` shrink in Slint — so between them they took
|
||
// about a third of a 768px header and left the buttons to scroll off the
|
||
// end of it.
|
||
//
|
||
// The selection count is gone from here entirely: it belongs beside the
|
||
// buttons that act on it, which is the bar at the foot of the grid.
|
||
//
|
||
// The other three are one subject — what this grid is showing and what is
|
||
// being done to it — so they are one sentence with separators, and one
|
||
// stretch between them. Two of them even said the same words while a sweep
|
||
// ran: "indexing 300 / 12 480" appeared as the status *and* as the window
|
||
// position.
|
||
//
|
||
// Assembled in two steps rather than one so the separators can be dropped
|
||
// along with the parts they follow. A library with nothing scanned and
|
||
// nothing loaded should read as empty, not as " · · ".
|
||
property <string> status-phrase:
|
||
root.scanning ? root.scan-status
|
||
: (root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.scan-status);
|
||
property <string> status-line:
|
||
(root.total > 0 ? root.total + " images" : "")
|
||
+ (root.status-phrase != ""
|
||
? (root.total > 0 ? " · " : "") + root.status-phrase
|
||
: "")
|
||
+ (root.window-label != ""
|
||
? ((root.total > 0 || root.status-phrase != "") ? " · " : "")
|
||
+ root.window-label
|
||
: "");
|
||
|
||
callback cell-clicked(int);
|
||
/// A star was clicked on a cell: row, and the rating 0..5.
|
||
callback cell-rated(int, int);
|
||
/// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back
|
||
/// up. Which of the two is decided in Rust, from what the catalog says the
|
||
/// group is currently doing, so the mark cannot get out of step with the
|
||
/// query that actually hides the frames.
|
||
callback burst-toggled(int);
|
||
/// A frame of an open burst was named as the one it folds to (FR-CULL-5),
|
||
/// by row. Nothing here decides *which* frame deserves it — the catalog's
|
||
/// default is the earliest, a fact about the clock rather than a judgement
|
||
/// about the photograph, and this is the photographer overruling it.
|
||
callback burst-representative-chosen(int);
|
||
/// Whether the grid is currently listing the trash rather than the
|
||
/// library. Suppresses the per-cell trash target, which would be inert
|
||
/// there — `plan_trash` skips an already-trashed image — and offering a
|
||
/// control that does nothing is worse than offering none.
|
||
in property <bool> viewing-trash: false;
|
||
/// The trash target was clicked on a cell. Acts on that one photograph,
|
||
/// like the stars beside it — the pointer names it unambiguously, and a
|
||
/// click that quietly trashed a whole selection would be a trap. `Delete`
|
||
/// is the bulk gesture.
|
||
callback cell-trashed(int);
|
||
/// Move the selection to the trash — the `Delete` key.
|
||
callback trash-selection();
|
||
/// A rating or flag key was pressed while the grid had focus. Applies to
|
||
/// the whole selection, which is what makes rating forty frames one
|
||
/// gesture.
|
||
///
|
||
/// Rating and flag travel on one callback because they are one keystroke
|
||
/// as far as the user is concerned; Rust decodes which axis was meant.
|
||
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
|
||
/// star, so neither axis is disturbed by a press on the other. The third
|
||
/// is `hover-ordinal`: which photograph the pointer is over, if any.
|
||
callback judged(int, int, int);
|
||
/// TRACES: FR-CAT-5 | FR-UI-5
|
||
/// A label key, with `hover-ordinal` as for `judged`.
|
||
callback labelled(int, int);
|
||
/// TRACES: FR-CAT-5 | FR-UI-4
|
||
/// A label chosen from the selection bar, 0 for none.
|
||
callback label-chosen(int);
|
||
/// Whether the selection bar's label picker is open. Local, like
|
||
/// `keywording`.
|
||
property <bool> labelling: false;
|
||
|
||
/// TRACES: FR-UI-5
|
||
/// The library ordinal of the cell under the pointer, -1 when none.
|
||
///
|
||
/// An ordinal and not a row of the loaded window, because the window moves
|
||
/// under a still pointer as the grid scrolls; and cleared when it scrolls,
|
||
/// so a key pressed after a wheel turn falls back to the selection rather
|
||
/// than judging whatever used to be under the pointer.
|
||
property <int> hover-ordinal: -1;
|
||
/// `F2` — rename the collection the grid is scoped to. The key lives with
|
||
/// the grid because that is what holds focus in library mode, but the
|
||
/// rename itself happens in the sidebar.
|
||
callback rename-scope();
|
||
/// Whether the collections sidebar is currently shown. The toggle for it
|
||
/// lives here rather than in the sidebar itself for the obvious reason: a
|
||
/// control inside a closed panel cannot reopen it.
|
||
in property <bool> collections-visible: true;
|
||
callback toggle-collections();
|
||
|
||
callback rescan();
|
||
/// Open the settings page.
|
||
/// TRACES: FR-CAT-10
|
||
/// Whether this platform can import at all. Forwarded to the header, which
|
||
/// hides the button rather than disabling it.
|
||
in property <bool> can-import: false;
|
||
|
||
callback open-import();
|
||
callback open-settings();
|
||
callback open-people();
|
||
/// TRACES: FR-UI-4
|
||
/// The gesture reference's rows, from Rust — which reads them from the
|
||
/// generated table. See gestures.slint for why they cannot be written here.
|
||
in property <[GestureRow]> gestures;
|
||
/// Whether the reference is up. Local, like `naming` and `filing`: nothing
|
||
/// in Rust needs to know a sheet is open.
|
||
property <bool> helping: false;
|
||
|
||
// --- selection and drag ---
|
||
//
|
||
// A click selects; ctrl-click adds to the selection; shift-click extends a
|
||
// range. Dragging a selected cell carries the whole selection, which is
|
||
// what makes "put these forty photographs in that collection" one gesture.
|
||
//
|
||
// The drag itself is Slint's own `DragArea`, not a hand-rolled gesture. The
|
||
// first attempt here tracked presses and travel through a `TouchArea` and
|
||
// failed for a reason worth recording: an interactive `Flickable` claims any
|
||
// drag that begins inside it for scrolling, cancelling the child
|
||
// TouchArea's press, so the gesture could never leave the grid. `DragArea`
|
||
// is arbitrated properly against the Flickable, keeps the pointer capture
|
||
// across component boundaries, and draws its own cursor overlay — which is
|
||
// also why there is no badge position to compute here any more.
|
||
/// Modifier state at press time, so Rust can decide replace / add / extend
|
||
/// without the .slint file encoding the selection policy.
|
||
callback cell-pressed(int, bool, bool);
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
||
/// the finger travelled. Cancels the long-press timer that would otherwise
|
||
/// turn a scroll into a selection. Rust owns that timer: Slint has no
|
||
/// long-press gesture, and a hand-rolled one here would need a `Timer` per
|
||
/// visible cell.
|
||
callback cell-press-ended();
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Whether a tap selects rather than opens.
|
||
///
|
||
/// Held in Rust beside the selection it modifies, so the long press and the
|
||
/// header's button are two doors into one state rather than two states that
|
||
/// can disagree. In this mode a plain tap toggles a cell — exactly what
|
||
/// ctrl-click does with a pointer, which is why the press below passes it
|
||
/// as the ctrl flag rather than as a third selection policy.
|
||
// GESTURE: Start selecting several photographs
|
||
// where: Library grid
|
||
// touch: Press and hold a photograph, or press Select in the header
|
||
// pointer: Ctrl-click, or press Select in the header
|
||
// why: Touch has no ctrl, so without a mode there is no way to
|
||
// select a second photograph — the first tap would open it.
|
||
// The hold is the fast way in and the button is the one that
|
||
// can be found.
|
||
//
|
||
// GESTURE: Add or remove one photograph
|
||
// where: Library grid
|
||
// touch: While selecting, tap it
|
||
// pointer: Ctrl-click it
|
||
// why: While selecting, a tap never opens. That is the whole point
|
||
// of the mode: one meaning per gesture at a time. Press Done to
|
||
// get tap-to-open back.
|
||
//
|
||
// GESTURE: Leave selecting
|
||
// where: Library grid
|
||
// touch: Press Done in the header
|
||
// pointer: Press Done in the header
|
||
// keys: Escape
|
||
in property <bool> select-mode: false;
|
||
callback toggle-select-mode();
|
||
|
||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||
/// The photograph a press has held long enough to pick up, or `-1`.
|
||
///
|
||
/// **The visible half of a gesture that was folklore.** A finger on a cell
|
||
/// is ambiguous — it may be starting a scroll or taking hold of a
|
||
/// photograph — and Slint resolves that by giving the `Flickable` the first
|
||
/// half-second: any press that travels more than a few pixels vertically
|
||
/// inside it becomes a scroll, and the drag never begins. Only a fast
|
||
/// sideways flick, or waiting the half-second out, ever picked a
|
||
/// photograph up, and nothing on the screen said so. The user's account of
|
||
/// it was that dragging "sometimes works".
|
||
///
|
||
/// So the wait is given a mark. The same hold that turns on selection mode
|
||
/// sets this, a ring opens outward around the cell, and from that moment
|
||
/// the drag is the only thing the finger can be doing — the grid below is
|
||
/// no longer `interactive`, so there is no scroll left to lose to. The cue
|
||
/// can only ever arrive *after* the ambiguity has passed, which is the
|
||
/// honest direction: once the ring is open, dragging works.
|
||
///
|
||
/// A row of the loaded window, like every other row here. It is cleared
|
||
/// when the press ends and when a drag finishes, and the grid cannot
|
||
/// scroll while it is set, so it cannot outlive the window it indexes.
|
||
// GESTURE: Pick a photograph up to drag it
|
||
// where: Library grid
|
||
// touch: Press and hold it until a ring opens around it, then drag
|
||
// pointer: Drag it
|
||
// why: A finger on a photograph might be starting a scroll, and for
|
||
// the first half-second the grid assumes it is. Holding says
|
||
// otherwise, and the ring is the grid saying it heard — from
|
||
// there the drag cannot be lost to a scroll. A mouse never
|
||
// waits: the cursor is precise enough that a sideways drag is
|
||
// unambiguous from the first pixel.
|
||
in-out property <int> held-row: -1;
|
||
/// TRACES: FR-CAT-5
|
||
// --- reordering a manual collection (FR-CAT-7) --------------------------
|
||
//
|
||
// `collection_members.position` and `Sort::CollectionPosition` have existed
|
||
// in the catalog since collections did, and nothing above it ever wrote or
|
||
// read them: the grid ordered everything by capture time, always. This is
|
||
// the gesture that makes the column mean something.
|
||
//
|
||
// Only where there is a manual order to change — a single manual collection
|
||
// with no children. A set interleaves two children's unrelated positions
|
||
// and a smart collection has no member rows at all, so both fall back to
|
||
// capture time and refuse the drop rather than pretending.
|
||
/// Whether a drop on a cell should move photographs within the collection
|
||
/// being shown. Set by Rust from the scope, because what a scope *is* is a
|
||
/// catalog question.
|
||
in property <bool> reorderable: false;
|
||
/// Move the selection so it sits beside the photograph at this row of the
|
||
/// loaded window — before it, or after it where the drop landed on the
|
||
/// cell's trailing half. The trailing half is what makes the last position
|
||
/// reachable at all; without it there is no cell to drop "before".
|
||
callback reorder-to(int, bool);
|
||
|
||
/// Drop the selection without leaving select mode.
|
||
callback clear-selection();
|
||
/// TRACES: FR-CAT-5 | FR-UI-4
|
||
/// Take everything the grid is currently showing — the whole library, or
|
||
/// the whole of whatever it is scoped and filtered to. Answered from the
|
||
/// catalog, not from the loaded window, for the reason `cell-pressed`'s
|
||
/// shift argument is: what is on screen is a fraction of what is meant.
|
||
callback select-all();
|
||
|
||
/// TRACES: FR-UI-2 | FR-UI-4
|
||
/// Whether the next cell tap should take everything from the last cell
|
||
/// tapped to it.
|
||
///
|
||
/// **Why this exists.** Touch has had a range gesture for as long as
|
||
/// selection mode has — double-tap the far end — and it was a gesture only
|
||
/// its author could find. It is invisible, it is unreliable on a grid that
|
||
/// scrolls under the second tap, and it extends from the anchor *before*
|
||
/// the two taps moved it, a rule subtle enough to need two paragraphs of
|
||
/// Rust to explain to itself.
|
||
///
|
||
/// This is the same operation made visible: a button that arms it, a strip
|
||
/// that says what the next tap will do, and a way out. It also does the one
|
||
/// thing a drag-to-select sweep could not — the user may scroll as far as
|
||
/// they like between the two taps, and the run is resolved by the catalog
|
||
/// rather than by what happens to be on screen. The ranges that hurt on a
|
||
/// tablet are longer than a screenful, which is exactly where a sweep runs
|
||
/// out.
|
||
///
|
||
/// Local to this file, and one-shot: the next press consumes it. Rust needs
|
||
/// no state for it, because it arrives as `cell-pressed`'s shift argument
|
||
/// and lands in `apply_press` as the shift-click it already knows how to
|
||
/// apply.
|
||
// GESTURE: Select a range
|
||
// where: Library grid
|
||
// touch: While selecting, press "Select to…", then tap the last
|
||
// photograph of the run
|
||
// pointer: Shift-click the last photograph of the run
|
||
// why: This replaced a double tap, which had no visible state and
|
||
// could take forty photographs by accident. The run is resolved
|
||
// by the catalog rather than by what is on screen, so the grid
|
||
// can scroll between the two taps — the ranges that hurt on a
|
||
// tablet are longer than a screenful, which is exactly where a
|
||
// finger sweep runs out.
|
||
property <bool> ranging: false;
|
||
|
||
// A range armed against nothing has no anchor to extend from, and the strip
|
||
// that says it is armed is only drawn while there is a selection — so
|
||
// "Done" in the header, which drops the selection from outside that strip,
|
||
// would leave the mode armed and invisible, and the next ordinary tap would
|
||
// take a run the user never asked for.
|
||
changed selected-count => {
|
||
if (root.selected-count == 0) {
|
||
root.ranging = false;
|
||
}
|
||
}
|
||
/// TRACES: FR-CAT-5
|
||
/// Make a new collection, under the given name, holding exactly what is
|
||
/// selected. The name arrives from the sheet below rather than being
|
||
/// invented by Rust and corrected afterwards — see `naming`.
|
||
callback collection-from-selection(string);
|
||
/// **What arms the drag, not what it carries.**
|
||
///
|
||
/// `DragArea` refuses to begin a drag while its `data` is empty, and it
|
||
/// asks on every pointer event — including the first one, long before
|
||
/// anything is being dragged. A binding that calls a callback is evaluated
|
||
/// once and cached, because there is nothing for Slint to invalidate it on,
|
||
/// so whatever this answers the first time a finger touches a cell is what
|
||
/// that cell's `DragArea` believes for the rest of its life.
|
||
///
|
||
/// It is therefore *not* the payload the drop reads, whatever it looks
|
||
/// like: what travels is `dragging` in `collections_ui`, recorded by
|
||
/// `drag-started` and read back by `dropped-on`. See the Rust side for why
|
||
/// this has to answer something non-empty unconditionally.
|
||
pure callback drag-payload() -> data-transfer;
|
||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||
/// of them where several are being carried. Composited in Rust, because
|
||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||
in property <image> drag-image;
|
||
/// A drag began on this cell.
|
||
///
|
||
/// This, not `drag-payload`, is where what the drag carries is decided: it
|
||
/// fires with the selection as it stands at the moment the drag starts, and
|
||
/// lets Rust promote an unselected cell into the selection first.
|
||
callback drag-started(int);
|
||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||
callback drag-finished();
|
||
|
||
// --- rating filter (FR-CAT-6, FR-CULL-4) ---
|
||
//
|
||
// The filter narrows what the grid *queries*, not what it draws: on a
|
||
// remote library, drawing then hiding would still have fetched every
|
||
// thumbnail, which is the cost FR-NC-3 exists to avoid.
|
||
/// Minimum stars to show. 0 shows everything.
|
||
in property <int> filter-min-rating: 0;
|
||
/// TRACES: FR-UI-5
|
||
/// The top of a star range; 5 is none.
|
||
in property <int> filter-max-rating: 5;
|
||
callback filter-rating-range(int, int);
|
||
/// Show only images nothing has judged yet — FR-CULL-4's "filter to
|
||
/// unjudged", which is what lets a culling session resume.
|
||
in property <bool> filter-unjudged: false;
|
||
/// 0 no flag filter, 1 picks only, 2 rejects only.
|
||
in property <int> filter-flag: 0;
|
||
/// How many images sit at each star count, index 0 being unrated. Shown
|
||
/// on the filter buttons so the user can see there is something behind a
|
||
/// filter before narrowing to it — a filter that silently empties the
|
||
/// grid reads as broken.
|
||
in property <[int]> rating-counts;
|
||
|
||
/// Whose photographs the grid is narrowed to — one chip each, so a
|
||
/// selection of three can be taken apart one person at a time.
|
||
in property <[PersonChip]> filter-people;
|
||
/// Everyone the library knows, for the tray. Filled on demand — see the
|
||
/// tray itself for why it is not simply pushed alongside the chips.
|
||
in property <[PersonChip]> people;
|
||
/// Asked for when the tray opens, so a roster is never built for a bar
|
||
/// nobody has opened and never goes stale in one that is.
|
||
callback people-listed();
|
||
/// Add or remove one person from the filter, keeping the rest.
|
||
///
|
||
/// The gesture the whole tray exists for: `filter-person-cleared` can only
|
||
/// take somebody *out*, so before this the only way to narrow to two people
|
||
/// at once was to visit the People screen twice.
|
||
callback filter-person-toggled(int);
|
||
/// Whether the people tray is open.
|
||
///
|
||
/// Private to the view, like every other disclosure here: nothing in Rust
|
||
/// needs to know, and a strip whose open state round-tripped through a
|
||
/// callback would flicker on every press.
|
||
property <bool> people-tray: false;
|
||
/// Whether those people are an intersection rather than a union.
|
||
in property <bool> filter-people-all: false;
|
||
callback filter-person-cleared(int);
|
||
callback filter-people-mode-toggled();
|
||
/// TRACES: FR-CULL-8a | FR-CULL-13
|
||
/// Only photographs in which the chosen people are not blinking. Shown
|
||
/// and meaningful only while `filter-people` holds someone.
|
||
in property <bool> filter-eyes-open: false;
|
||
callback filter-eyes-open-toggled();
|
||
callback filter-min-rating-changed(int);
|
||
callback filter-unjudged-toggled(bool);
|
||
callback filter-flag-changed(int);
|
||
/// TRACES: FR-CAT-6
|
||
/// The colour label narrowed to, 0 for none, and each label's count.
|
||
in property <int> filter-label: 0;
|
||
in property <[int]> label-counts;
|
||
callback filter-label-changed(int);
|
||
|
||
// --- offline (FR-CAT-9) -------------------------------------------------
|
||
//
|
||
// Offline is a banner rather than a modal or an empty state, because most
|
||
// of the library still works: the shards hold the thumbnails, and rating,
|
||
// flagging, sorting and collecting are catalog writes that never touched
|
||
// the network. Only opening an un-cached original actually fails.
|
||
in property <bool> offline: false;
|
||
in property <string> offline-reason: "";
|
||
in property <string> offline-since: "";
|
||
callback retry-connection();
|
||
|
||
// --- pinning (FR-NC-6a) -----------------------------------------------
|
||
//
|
||
// How far the download has got. Progress is shown because pinning a trip
|
||
// is gigabytes of transfer — a button that appeared to do nothing for
|
||
// twenty minutes would read as broken.
|
||
//
|
||
// Only the progress. Asking for the pin, and saying whether the scoped
|
||
// collection already has one, belong to the collections panel — the tree
|
||
// is what decides which collection is meant. This bar reports on the
|
||
// transfer wherever it was started, including from a row this grid is not
|
||
// scoped to, which is why it is not gated on the scope.
|
||
in property <int> pin-done: 0;
|
||
in property <int> pin-total: 0;
|
||
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
||
in property <bool> local-only: false;
|
||
in property <int> local-count: 0;
|
||
callback toggle-local-only();
|
||
/// Whether the grid is narrowed to the timeline's visible span.
|
||
in property <bool> range-active: false;
|
||
callback toggle-date-range();
|
||
/// TRACES: FR-CAT-6
|
||
/// The same range as fractions of the span the axis is drawn over, for
|
||
/// the band on the timeline. Negative in both when there is none.
|
||
///
|
||
/// A second representation of one range, which is worth it: the fields
|
||
/// below are how a date is *stated* and the band is how a period is
|
||
/// *found*, and neither is a good way to do the other's job. Both come
|
||
/// from the one filter in Rust, so they cannot drift apart.
|
||
in property <float> range-from-fraction: -1;
|
||
in property <float> range-to-fraction: -1;
|
||
callback timeline-range-changed(float, float);
|
||
/// The ends of the range, as `YYYY-MM-DD`, when one is set.
|
||
///
|
||
/// Strings rather than instants because this is what the user types and
|
||
/// what they read back. Rust parses them and refuses what is not a date;
|
||
/// `range-invalid` is how that refusal reaches the field, since a filter
|
||
/// that silently ignored a typo would show an empty grid and no reason.
|
||
in property <string> range-from;
|
||
in property <string> range-to;
|
||
in property <bool> range-invalid: false;
|
||
/// Both ends at once: they are one range, and applying half of an edit
|
||
/// would filter to a span the user never asked for.
|
||
callback range-edited(string, string);
|
||
|
||
/// How many images are selected, for the header's count.
|
||
in property <int> selected-count: 0;
|
||
|
||
// TRACES: FR-DEV-6
|
||
// Batch-applying copied develop settings to the selection. The clipboard
|
||
// itself belongs to the window — a copy is taken in the develop view and
|
||
// pasted here — so the grid only reports what it has and asks.
|
||
in property <bool> settings-armed: false;
|
||
callback paste-settings-to-selection();
|
||
/// TRACES: FR-DEV-6
|
||
callback open-presets();
|
||
|
||
// TRACES: FR-EXP-7
|
||
// Exporting the selection. The grid owns neither the settings that decide
|
||
// where the files go nor the worker that writes them — it reports what is
|
||
// selected and asks, exactly as it does for a paste.
|
||
in property <bool> exporting: false;
|
||
in property <bool> export-to-server: false;
|
||
callback export-selection();
|
||
callback cancel-export();
|
||
/// TRACES: FR-EXP-7 | FR-UI-5
|
||
/// The export sheet, which the window draws: Ctrl+E.
|
||
callback open-export-options();
|
||
/// A sheet is drawn over the grid, so the keys here stand down.
|
||
in property <bool> sheet-open: false;
|
||
// TRACES: FR-MRG-1
|
||
// Merge the selection into a panorama. Two frames at least; the page
|
||
// that opens says what it found and asks before anything is written.
|
||
callback merge-selection();
|
||
|
||
// --- the keyboard cursor ------------------------------------------------
|
||
//
|
||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
||
// row of the loaded window, which names a different photograph after every
|
||
// scroll. Rust owns it, because clamping it needs the library's length and
|
||
// moving it may have to swap the window underneath.
|
||
//
|
||
// -1 before the user has taken hold of it, so a fresh grid draws no cursor
|
||
// and the first arrow press picks up where the view already is rather than
|
||
// teleporting to image zero.
|
||
in property <int> cursor: -1;
|
||
/// Move the cursor by a number of images; the flag extends the selection
|
||
/// from the anchor instead of replacing it.
|
||
///
|
||
/// A signed step and nothing else. Home and End are this with a step
|
||
/// longer than the library, which Rust clamps — so this file needs to know
|
||
/// neither how many images there are nor where the loaded window starts.
|
||
callback move-cursor(int, bool);
|
||
/// Open the image under the cursor. `Return`, and the reason the arrows
|
||
/// are worth having: a cull is walk, judge, open, back, without the hand
|
||
/// ever leaving the keyboard.
|
||
callback open-cursor();
|
||
/// Which collection scopes the grid, for the header. Empty means all.
|
||
in property <string> scope-label: "";
|
||
/// Take the selection out of the collection currently being shown. Only
|
||
/// offered when the grid is scoped to one — "remove from library" is not a
|
||
/// thing this button does.
|
||
callback remove-from-collection();
|
||
/// Show every collection the selection is filed in, with a way out of each.
|
||
/// The button above can only ever name the scoped collection; this is how a
|
||
/// photograph leaves one the grid is not currently showing — and the only
|
||
/// way to find out which collections it is in at all, since the cell badges
|
||
/// a count and nothing else.
|
||
callback open-membership();
|
||
|
||
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
|
||
//
|
||
// The drag onto the sidebar is the fast way to file photographs, and it is
|
||
// a pointer gesture: a one-finger drag beginning inside the grid belongs to
|
||
// the Flickable that scrolls it, which is the arbitration described at the
|
||
// top of `collections_ui.rs` working exactly as it should. So touch needs a
|
||
// way in that is not a drag, and that is this sheet.
|
||
//
|
||
// The rows are the sidebar's own model, passed through rather than queried
|
||
// again: two lists of collections is one list too many, and the one that
|
||
// goes stale is always the one nobody is looking at.
|
||
in property <[CollectionRow]> collections;
|
||
/// Whether the sheet is up. Local, because it is a disclosure rather than a
|
||
/// preference — nothing outside this file needs to know it is open, and
|
||
/// what closes it is choosing a collection or dismissing it.
|
||
property <bool> filing: false;
|
||
/// Whether the images should *leave* the collection being shown, rather
|
||
/// than being filed in a second one as well. Only meaningful while scoped,
|
||
/// and reset every time the sheet opens: a destructive default that
|
||
/// remembered itself between uses is how photographs go missing.
|
||
property <bool> filing-moves: false;
|
||
/// File the selection: the target collection's id, and whether to take it
|
||
/// out of the one currently being shown.
|
||
callback file-in-collection(int, bool);
|
||
|
||
// --- keywording the selection (FR-CAT-5, FR-CAT-6) ----------------------
|
||
//
|
||
// The catalog has been searchable by keyword since it existed and there was
|
||
// never anywhere to type one. This sheet is that place, and it sits beside
|
||
// the filing sheet above because the two are the same gesture applied to
|
||
// two different kinds of label — pick the photographs, then say what they
|
||
// are — and a user who has learnt one should not have to learn the other.
|
||
//
|
||
// Assign and unassign travel by **name**, not by id. A word typed into the
|
||
// field and a word tapped in the list are then one path through Rust rather
|
||
// than two, and the sheet does not have to invent an id for a keyword that
|
||
// does not exist yet.
|
||
/// The vocabulary, already answered against the current selection.
|
||
in property <[KeywordRow]> keywords;
|
||
/// The sheet is opening: Rust answers by refreshing `keywords` against
|
||
/// whatever is selected *now*.
|
||
///
|
||
/// Pulled on open rather than pushed on every selection change, because the
|
||
/// selection changes on every arrow key and the sheet is shut for almost
|
||
/// all of them — recomputing coverage over a forty-image selection for a
|
||
/// panel nobody is looking at is work the grid cannot afford.
|
||
callback keywords-opened();
|
||
callback assign-keyword(string);
|
||
callback unassign-keyword(string);
|
||
/// Whether the sheet is up. Local, for the same reason `filing` is: it is a
|
||
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||
property <bool> keywording: false;
|
||
|
||
// --- naming a new collection (FR-CAT-5, FR-CAT-7) -----------------------
|
||
//
|
||
// "New collection from selection" used to create the collection under a
|
||
// placeholder name and then open the rename field in the sidebar tree.
|
||
//
|
||
// On a tablet the sidebar is not on screen. It is instantiated all the
|
||
// same — `app.slint` collapses it to zero width and `visible: false`
|
||
// rather than using an `if`, because an `if` there is a layout loop Slint
|
||
// panics on — so the rename field was created, its `init` took focus, and
|
||
// Android raised the on-screen keyboard for a box nobody could see. Nothing
|
||
// else on the screen is focusable, so the keyboard had nowhere to go: it
|
||
// stayed, the name could not be typed, and the collection was already
|
||
// written under the name the user did not want.
|
||
//
|
||
// Asked here instead, before anything is written. A sheet dismissed leaves
|
||
// no collection behind, which the create-then-rename order could not
|
||
// promise.
|
||
/// Whether the naming sheet is up. Local, like `filing` and `keywording`.
|
||
property <bool> naming: false;
|
||
|
||
// Cell geometry. Columns are derived from the available width so the grid
|
||
// reflows with the window rather than fixing a count (FR-UI-1).
|
||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||
// judging one, and more, smaller ones for finding one. Driven from Rust so
|
||
// the value survives a scope change and the thumbnail class can follow it.
|
||
/// The size class the user chose. What is actually drawn is `cell-size`
|
||
/// below, which is this capped to what the grid can hold.
|
||
in property <length> requested-cell-size: 180px;
|
||
|
||
// --- what the grid is measured against ---------------------------------
|
||
//
|
||
// **`grid-area`, not `self`.** Every quantity below describes the box the
|
||
// cells are actually drawn in, and that box is *not* this component: the
|
||
// capture-time axis is a sibling of it, 96px wide, and the header sits
|
||
// above it. Measuring against `self.width` therefore counted the timeline
|
||
// as room for thumbnails and fitted one more column than there was space
|
||
// for — the last column ran off the right-hand edge, clipped by the
|
||
// Flickable, with no way to scroll sideways to it. At 180px cells that is
|
||
// a whole column lost on a phone in portrait, where 96px of 400 is a
|
||
// quarter of the screen.
|
||
//
|
||
// Reading a descendant's geometry is safe here because nothing derived
|
||
// from it feeds back into the layout: the cells are placed absolutely
|
||
// inside the Flickable, and a Flickable's own layout constraints are a
|
||
// bare `stretch: 1` that its viewport cannot influence. That is the loop
|
||
// the comments on `panel-visible` in app.slint warn about, and this is not
|
||
// an instance of it.
|
||
|
||
/// How many columns of roughly the requested size the grid holds.
|
||
///
|
||
/// **Rounded, not floored.** The size class is a request, not a
|
||
/// measurement — what the user is choosing is "about this big" — so a
|
||
/// width that is nine tenths of the way to another column should take it
|
||
/// rather than leave nearly a whole column of dead space at the edge.
|
||
///
|
||
/// A minimum of one, so a grid narrower than a single cell still draws
|
||
/// that cell rather than none.
|
||
property <int> columns:
|
||
max(1, round((grid-area.width - Theme.gap)
|
||
/ (root.requested-cell-size + Theme.gap)));
|
||
|
||
/// The drawn cell: the width, divided exactly.
|
||
///
|
||
/// **The tiles fill the grid.** They used to be drawn at whatever the size
|
||
/// class asked for and the remainder left as a bare strip down the
|
||
/// right-hand side — up to one cell short of a full column of nothing,
|
||
/// which on a phone is a quarter of the screen. Worse, the column count
|
||
/// was derived from that same fixed size, so the two disagreed about how
|
||
/// much room there was and the last column could start inside the viewport
|
||
/// and end outside it.
|
||
///
|
||
/// Solving for the cell instead removes both faults at once: pick how many
|
||
/// columns to draw, then make them share the width. `columns` cells and
|
||
/// the `columns + 1` gaps around them come to exactly `grid-area.width`,
|
||
/// so there is no remainder to strand and nothing can overhang.
|
||
///
|
||
/// The size class still decides what the user gets — it is what `columns`
|
||
/// is chosen from — it just no longer dictates the pixel, so the cell
|
||
/// flexes by a few percent either way to make the row come out even.
|
||
property <length> cell-size:
|
||
max(64px,
|
||
(grid-area.width - Theme.gap * (root.columns + 1)) / root.columns);
|
||
// Reported out so Rust can place month headings: a heading belongs on a
|
||
// cell that begins a row, and only the grid knows how wide a row is.
|
||
changed columns => { root.columns-changed(root.columns); }
|
||
// And once on creation. `changed` reports a change, and the first
|
||
// evaluation is not one — so a grid built after the window has already
|
||
// settled at its size never said how wide it was, and Rust went on
|
||
// placing headings for the one column it had been told about at
|
||
// start-up. Every month then began a row and was announced wherever
|
||
// its first cell fell, mid-row included.
|
||
init => { root.columns-changed(root.columns); }
|
||
property <int> row-count: ceil(root.cells.length / max(1, columns));
|
||
|
||
// --- while a pinch is happening, and just after -----------------------
|
||
//
|
||
// **A pinch does not end cleanly.** Lifting one finger of two leaves the
|
||
// other one down, and Slint replays that survivor as a *fresh* `Pressed`
|
||
// on whatever is under it — that is how it hands the pointer back to
|
||
// ordinary handling. Under it is a cell. So the cell was selected, and
|
||
// then lifting that last finger was a complete, well-formed click and the
|
||
// photograph opened. Checking the finger id, which is what stops the
|
||
// *second* finger's synthetic release from opening anything, cannot help
|
||
// here: this press and this release are genuinely the same finger.
|
||
//
|
||
// Nothing in the event stream distinguishes that survivor from a real tap,
|
||
// so the grid has to remember that a pinch just happened. The latch is
|
||
// raised when the gesture starts and lowered a beat after it ends.
|
||
// --- has this session been touched? -----------------------------------
|
||
//
|
||
// **Hover is not a thing a finger does, but Slint reports it anyway.**
|
||
// `has-hover` goes true for any pointer event carrying a position, a touch
|
||
// press included, and false again on the `Exit` that follows the release.
|
||
// So on a tablet the rating strip did appear — for exactly the length of a
|
||
// tap. It flashed on under the finger, vanished as it lifted, and the tap
|
||
// went through to the cell and opened the photograph. There was no way to
|
||
// rate an unjudged frame from the grid at all.
|
||
//
|
||
// The strip therefore stops keying off hover as soon as there is evidence
|
||
// that this is a touch session, and evidence is what `touch-finger-id`
|
||
// is: zero for a mouse, never zero for a finger. Latched rather than
|
||
// sampled per event, because the strip has to be on screen *before* the
|
||
// finger arrives to be worth aiming at.
|
||
//
|
||
// One-way on purpose. A tablet with a mouse plugged in keeps the strips
|
||
// once it has been touched, which is the harmless direction to be wrong
|
||
// in — the alternative is chrome that comes and goes as the user changes
|
||
// hands.
|
||
//
|
||
// Seeded by Rust as well as latched here, because the latch alone needs a
|
||
// press to reach a cell and a quick flick never delivers one — the
|
||
// Flickable claims the gesture before the delay it forwards after. On
|
||
// Android touch is not evidence to be gathered, it is the platform, so it
|
||
// starts true there and this is left to catch a touchscreen on the desktop.
|
||
in-out property <bool> touched: false;
|
||
|
||
property <bool> pinching: false;
|
||
settle := Timer {
|
||
interval: 350ms;
|
||
running: false;
|
||
triggered => {
|
||
root.pinching = false;
|
||
self.running = false;
|
||
}
|
||
}
|
||
|
||
// How tall a screenful is, in rows. Measured rather than fixed: the same
|
||
// constant is simultaneously too small on a maximised 4K window and
|
||
// wasteful on a narrow one, and everything the loaded window does is
|
||
// expressed as a multiple of this.
|
||
property <int> visible-rows:
|
||
max(1, ceil(grid-area.height / (cell-size + Theme.gap)));
|
||
/// Cells the viewport shows at once, counting the row the scroll position
|
||
/// has cut in half.
|
||
///
|
||
/// `visible-rows` is a ceiling on a grid whose first row starts at the top
|
||
/// of the viewport, and the grid is only ever aligned like that at rest at
|
||
/// the very top. Scrolled anywhere else, a part-row hangs off each end and
|
||
/// the viewport touches one row more than that — the row this used to
|
||
/// undercount is the bottom one, which is the row reported missing.
|
||
property <int> viewport-cells: root.columns * (root.visible-rows + 1);
|
||
changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); }
|
||
/// Rows the *whole library* occupies, which is what the scrollbar spans.
|
||
property <int> total-rows: ceil(root.total / max(1, columns));
|
||
|
||
background: Theme.ground;
|
||
|
||
VerticalLayout {
|
||
// --- header -------------------------------------------------------
|
||
Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
// Scrolls rather than overflowing, for the reason set out on the
|
||
// filter row below: a layout given less width than its children
|
||
// need does not shrink them, it overruns *and* reports the
|
||
// oversized minimum upwards — which is how a header of buttons
|
||
// ended up dictating how many columns of thumbnails the grid
|
||
// thought it could draw. Seven action buttons and four readouts do
|
||
// not fit across a tablet even in landscape, which is why the last
|
||
// one was clipped by the screen edge.
|
||
//
|
||
// Sized to `min-width`, not `preferred-width`: the latter is the
|
||
// row's *untruncated* footprint — every readout at its natural
|
||
// text width — so the Flickable was always at least that wide and
|
||
// the row never actually shrank, it just scrolled, with the
|
||
// sidebar toggle and the "More" disclosure off past the right
|
||
// edge on anything narrower than a tablet. `min-width` is what
|
||
// the row needs once its `elide` + `horizontal-stretch: 1`
|
||
// readouts (image count, selection count, scan status, the
|
||
// window label below) are collapsed to an ellipsis, so a window
|
||
// with room for that shrunk row now gets the row shrunk to fit
|
||
// instead of scrolled — and the toggle and disclosure, which
|
||
// don't shrink, stay on screen. Only a window too narrow even for
|
||
// the shrunk row still scrolls.
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, header-row.min-width);
|
||
|
||
header-row := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
// The sidebar toggle, leading the header — the place every
|
||
// interface with a collapsible sidebar puts one, and the only
|
||
// place that stays put whichever way the panel is.
|
||
IconButton {
|
||
icon: "menu";
|
||
active: root.collections-visible;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.toggle-collections(); }
|
||
}
|
||
|
||
Value {
|
||
// The collection being shown takes the title when the grid
|
||
// is scoped to one: that is what the user narrowed to, and
|
||
// the folder is the less specific fact by then.
|
||
text: root.scope-label != "" ? root.scope-label
|
||
: (root.root-label != "" ? root.root-label : "Library");
|
||
overflow: elide;
|
||
// A floor, for the same reason the status line beside it
|
||
// has one — and it is named here because the two of them
|
||
// compete. An eliding `Text` has a minimum of nothing, so
|
||
// once the buttons had claimed their own minimums the
|
||
// title was the cheapest thing in the row to give away and
|
||
// it went to a bare "…": the window's own name, gone,
|
||
// while the byte counts beside it stayed. Whatever else
|
||
// this row drops, it may not stop saying what is being
|
||
// looked at.
|
||
min-width: 90px;
|
||
}
|
||
|
||
// Everything the grid has to say about itself, in one line:
|
||
// how many images, what is running over them, and where in the
|
||
// library the visible window sits. See `status-line` for why
|
||
// the four readouts that used to be here are one.
|
||
//
|
||
// `elide` + `horizontal-stretch: 1`: stretch is what actually
|
||
// lets a `Text` shrink under pressure in Slint, not `elide`
|
||
// alone (see the comment on the header row's Flickable) —
|
||
// without it this readout keeps its full natural width and is
|
||
// what pushes the sidebar toggle and the "More" button past
|
||
// the right edge on a narrow window, reachable only by knowing
|
||
// to flick-scroll.
|
||
Caption {
|
||
text: root.status-line;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
// Deliberately without a `min-width`, unlike the title.
|
||
//
|
||
// This row cannot always hold everything: after the
|
||
// sidebar takes its 232, a 1100pt window leaves the header
|
||
// about 868, and the buttons alone want most of that
|
||
// before a single character is drawn. Something has to
|
||
// give, and the order matters — a floor here bought a
|
||
// readable status by pushing `Settings` off the right
|
||
// edge, reachable only by knowing to flick-scroll, which
|
||
// is the exact fault this row's Flickable comment warns
|
||
// about.
|
||
//
|
||
// So this is what gives. It is the most redundant thing in
|
||
// the header — the sidebar states the library's count and
|
||
// the filter chips state it again — and it grows back the
|
||
// moment there is room.
|
||
}
|
||
|
||
// Expanded: the actions sit in the header as one row. There
|
||
// is room, and a disclosure would be a click in front of a
|
||
// button that was already visible.
|
||
if root.expanded: HeaderActions {
|
||
scanning: root.scanning;
|
||
syncing: root.syncing;
|
||
can-import: root.can-import;
|
||
select-mode: root.select-mode;
|
||
toggle-select-mode => { root.toggle-select-mode(); }
|
||
sync-now => { root.sync-now(); }
|
||
rescan => { root.rescan(); }
|
||
open-import => { root.open-import(); }
|
||
open-settings => { root.open-settings(); }
|
||
open-people => { root.open-people(); }
|
||
open-gestures => { root.helping = true; }
|
||
}
|
||
|
||
// Compact: one button in place of the row. Labelled rather
|
||
// than a bare glyph, because "⋯" alone in a header of words
|
||
// reads as a truncation of the label beside it — which is
|
||
// exactly what this change exists to stop happening.
|
||
if !root.expanded: Button {
|
||
text: root.actions-open ? "Close" : "More";
|
||
active: root.actions-open;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.actions-open = !root.actions-open; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The compact action row, disclosed by "More" above.
|
||
//
|
||
// A row beneath the header rather than a popup over the grid: a popup
|
||
// needs a dismiss rule, an anchor and a decision about what happens
|
||
// when the window resizes under it, and all this needs to be is the
|
||
// library's buttons somewhere they fit. It closes itself when the
|
||
// window widens — see `actions-open`.
|
||
if !root.expanded && root.actions-open: Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
// Scrolls, like the two rows above it. This is the row that exists
|
||
// *because* the header did not fit, so it is the last place to
|
||
// assume its buttons will.
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, actions-row.preferred-width);
|
||
|
||
actions-row := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
alignment: start;
|
||
|
||
HeaderActions {
|
||
centred: true;
|
||
scanning: root.scanning;
|
||
syncing: root.syncing;
|
||
can-import: root.can-import;
|
||
select-mode: root.select-mode;
|
||
toggle-select-mode => { root.toggle-select-mode(); }
|
||
sync-now => { root.sync-now(); }
|
||
rescan => { root.rescan(); }
|
||
open-import => { root.open-import(); }
|
||
open-settings => { root.open-settings(); }
|
||
open-people => { root.open-people(); }
|
||
open-gestures => { root.helping = true; }
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- rating filter ------------------------------------------------
|
||
//
|
||
// Hidden while there is nothing to filter: an empty library offering
|
||
// six rating buttons is chrome describing data that does not exist.
|
||
if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged
|
||
|| root.filter-flag > 0 || root.filter-label > 0 || root.filter-people.length > 0
|
||
|| root.filter-eyes-open: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
// --- the chips scroll rather than overflowing --------------
|
||
//
|
||
// **A layout cannot be narrower than its children's minimums.**
|
||
// Given less room than they need, Slint lays them out at their
|
||
// minimums and lets the row run past the edge — and, worse, the
|
||
// row reports that oversized minimum upwards. This row is a
|
||
// sibling of the grid inside one VerticalLayout, so its minimum
|
||
// became the *whole view's* minimum: `LibraryGrid` was laid out
|
||
// wider than the window, the grid measured itself against that
|
||
// inflated box, and the right-hand column was computed to fit in
|
||
// space that was off the screen. Fourteen chips do not fit across
|
||
// 768 logical pixels, so that is every tablet in portrait.
|
||
//
|
||
// A Flickable's own minimum is nothing — it is built to be smaller
|
||
// than what it holds — so wrapping the row both stops it inflating
|
||
// anything and makes the chips past the edge reachable instead of
|
||
// merely absent.
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
// Horizontal only: there is one row of chips and it must not
|
||
// drift vertically inside a 34px strip.
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, chips.preferred-width);
|
||
|
||
chips := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: 4px;
|
||
alignment: start;
|
||
|
||
// First on the bar, ahead of "Show". Arriving here from the
|
||
// People screen replaces the whole grid, and a chip explaining
|
||
// that has to be the first thing read — a user who does not
|
||
// find it is looking at a library that has apparently lost
|
||
// most of its photographs.
|
||
for p[i] in root.filter-people: FilterChip {
|
||
icon: "cross";
|
||
label: p.name;
|
||
active: true;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.filter-person-cleared(p.id); }
|
||
}
|
||
|
||
// Only with two, because with one the modes are the same
|
||
// filter and a toggle that changes nothing is a control that
|
||
// teaches the user it does nothing.
|
||
if root.filter-people.length > 1: FilterChip {
|
||
label: root.filter-people-all ? "all of them" : "any of them";
|
||
active: root.filter-people-all;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.filter-people-mode-toggled(); }
|
||
}
|
||
|
||
// Beside the people it applies to, and only with some chosen:
|
||
// "Anna, eyes open" is the question, and without a name in
|
||
// front of it the chip would be a judgement on everyone in
|
||
// the frame. A filter, never a verdict: it hides the blinks
|
||
// in a burst and rates nothing (FR-CULL-13).
|
||
// GESTURE: Take the blinks out of a burst
|
||
// where: Library grid
|
||
// touch: Narrow to a person, then tap "Eyes open" beside
|
||
// their name on the filter bar
|
||
// pointer: Narrow to a person, then click "Eyes open" beside
|
||
// their name on the filter bar
|
||
// why: Face indexing reads each face's eyes. The chip
|
||
// drops frames where the chosen people are caught
|
||
// blinking, and leaves sunglasses and eyes it could
|
||
// not read alone.
|
||
if root.filter-people.length > 0: FilterChip {
|
||
icon: "eye";
|
||
label: "Eyes open";
|
||
active: root.filter-eyes-open;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.filter-eyes-open-toggled(); }
|
||
}
|
||
|
||
// The way in to the people tray, and the reason it exists.
|
||
//
|
||
// Narrowing to *two* people at once was already possible and
|
||
// effectively unreachable: the only control that could add a
|
||
// second one lived on the People screen, behind selecting them
|
||
// there, and it only appeared once the grid was already
|
||
// narrowed to somebody. So a user who wanted "photographs with
|
||
// both of them" had to guess a two-screen round trip. A filter
|
||
// belongs on the filter bar; this chip is the whole feature's
|
||
// front door and the tray below is where both terms and the
|
||
// any/all choice actually are.
|
||
// GESTURE: Find photographs with two people in them
|
||
// where: Library grid
|
||
// touch: Open the People chip on the filter bar, tap each
|
||
// name, then switch the chip beside them to "all of
|
||
// them"
|
||
// pointer: Open the People chip on the filter bar, click each
|
||
// name, then switch the chip beside them to "all of
|
||
// them"
|
||
// why: "Any of them" is a union and "all of them" is an
|
||
// intersection. The tray is where both terms and the
|
||
// choice between them live, because a filter belongs
|
||
// on the filter bar.
|
||
FilterChip {
|
||
icon: root.people-tray ? "chevron-down" : "chevron-right";
|
||
label: "People";
|
||
// The number narrowed to, not the size of the roster: it
|
||
// says what the filter is doing, which is what every other
|
||
// count on this bar says.
|
||
count: root.filter-people.length > 0
|
||
? root.filter-people.length : -1;
|
||
active: root.people-tray;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.people-tray = !root.people-tray;
|
||
// Asked for on open rather than kept in step, so the
|
||
// roster is current — indexing and regrouping change
|
||
// who exists, and a list cached at startup would be
|
||
// stale for exactly the user who has just been naming
|
||
// people.
|
||
if (root.people-tray) { root.people-listed(); }
|
||
}
|
||
}
|
||
|
||
Caption {
|
||
text: "Show";
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
// Minimum-stars buttons. "All" first, then 1..5 — the same
|
||
// left-to-right increasing order as the star strip itself, so
|
||
// the two read as the same scale.
|
||
FilterChip {
|
||
label: "All";
|
||
active: root.filter-min-rating == 0 && root.filter-max-rating >= 5
|
||
&& !root.filter-unjudged && root.filter-flag == 0
|
||
&& root.filter-label == 0;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-rating-range(0, 5);
|
||
root.filter-unjudged-toggled(false);
|
||
root.filter-flag-changed(0);
|
||
root.filter-label-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;
|
||
// Under a capped range (the F chord), every chip inside
|
||
// it is lit, so "2 and 3 only" reads as two chips rather
|
||
// than as the "2+" it is not.
|
||
active: root.filter-max-rating < 5
|
||
? (n >= root.filter-min-rating && n <= root.filter-max-rating)
|
||
: 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 && root.filter-max-rating >= 5
|
||
? 0 : n);
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-UI-5
|
||
// A ceiling is the one star filter the chips cannot state, so
|
||
// it is stated in words while it holds.
|
||
if root.filter-max-rating < 5: Caption {
|
||
text: root.filter-max-rating == 0 && root.filter-min-rating == 0
|
||
? "no stars only"
|
||
: (root.filter-min-rating == root.filter-max-rating
|
||
? root.filter-min-rating + "★ only"
|
||
: root.filter-min-rating + "–" + root.filter-max-rating + "★ only");
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
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; }
|
||
|
||
// GESTURE: Show only photographs with one colour label
|
||
// where: Library grid
|
||
// touch: Tap its chip in the filter bar
|
||
// pointer: Click its chip in the filter bar
|
||
// why: Each chip is the label's mark and its name, so
|
||
// the one you want is found by reading it; tap
|
||
// the lit chip again to show every label.
|
||
//
|
||
// TRACES: FR-CAT-6 | NFR-A11Y-3
|
||
// One chip per colour label, each the cell's own mark and the
|
||
// label's name — never a bare swatch, which is the control
|
||
// NFR-A11Y-3 was written against. One label at a time, and
|
||
// pressing the lit one clears it, as the flag chips do.
|
||
for n in [1, 2, 3, 4, 5]: FilterChip {
|
||
colour-label: n;
|
||
label: Labels.names[n];
|
||
count: root.label-counts.length > n ? root.label-counts[n] : -1;
|
||
active: root.filter-label == n;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-label-changed(root.filter-label == n ? 0 : n);
|
||
}
|
||
}
|
||
|
||
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.filter-label > 0
|
||
|| root.local-only
|
||
|| root.filter-eyes-open)
|
||
? "filtered" : "";
|
||
emphasised: true;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- the people tray ----------------------------------------------
|
||
//
|
||
// A second strip under the filter bar rather than a popup, for the
|
||
// reason the develop column's film picker gives: this view already
|
||
// scrolls as one, so an inline strip is taller content and not a second
|
||
// overlay with its own dismiss gesture to lose a drag to.
|
||
//
|
||
// Horizontally scrolling, exactly like the bar above it and for the
|
||
// same hard reason — **a layout cannot be narrower than its children's
|
||
// minimums**, and a library with forty people would otherwise report a
|
||
// minimum width of forty chips and inflate the whole view. See the bar
|
||
// above for the full account of that fault.
|
||
if root.people-tray: Rectangle {
|
||
height: 38px;
|
||
background: Theme.surface;
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
Flickable {
|
||
width: 100%;
|
||
height: 100%;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, people-row.preferred-width);
|
||
|
||
people-row := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: 4px;
|
||
alignment: start;
|
||
|
||
Caption {
|
||
// Says what a *pair* of chips will mean before either
|
||
// is pressed, which is the thing the old design never
|
||
// said anywhere.
|
||
text: root.filter-people-all
|
||
? "In every one:" : "In the picture:";
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
// The roster. Ordered most-photographed-first with the
|
||
// named ahead of the rest, so the people a user actually
|
||
// intersects are the ones under the thumb without
|
||
// scrolling.
|
||
for p[i] in root.people: FilterChip {
|
||
icon: p.picked ? "check" : "";
|
||
label: p.name;
|
||
count: p.faces;
|
||
active: p.picked;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.filter-person-toggled(p.id); }
|
||
}
|
||
|
||
// Not an error and not empty chrome: face indexing is an
|
||
// opt-in overnight pass, so "nobody yet" is the ordinary
|
||
// state of a library nobody has run it on, and it should
|
||
// say where the pass lives.
|
||
if root.people.length == 0: Caption {
|
||
text: "Nobody indexed yet — find faces on the People screen.";
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the date range's ends ---------------------------------------
|
||
//
|
||
// Its own strip, a sibling of the chips rather than a child of them.
|
||
//
|
||
// Twice now this control has looked broken. First it narrowed to the
|
||
// whole library, because it took its span from a timeline zoom that
|
||
// is zero until someone zooms. Then the fields that fixed that went
|
||
// into the chip row — which is 34px tall and scrolls sideways, so
|
||
// they were both clipped and off past the right-hand edge.
|
||
//
|
||
// They are no longer the only way to state a range — the band on the
|
||
// axis is, and on a phone it is the only usable one. These stay for
|
||
// the two things dragging cannot do: name an exact day, and say what
|
||
// the range currently is in words rather than as a position.
|
||
//
|
||
// A `Rectangle` stacks its children at the origin rather than laying
|
||
// them out, which is why putting a second row inside the chips' one
|
||
// drew it over them instead of under them. This is a row of the
|
||
// header's `VerticalLayout`, so it gets a line of its own and the
|
||
// width of the window.
|
||
if root.range-active: Rectangle {
|
||
height: 40px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
width: 100%;
|
||
height: 100%;
|
||
// The same leading inset the chips above use, so the two rows
|
||
// start on one vertical line rather than a few pixels apart.
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
// Fixed-width fields and a two-letter word: left-aligned, so
|
||
// they sit under the chip that turned them on instead of
|
||
// spreading across the window.
|
||
alignment: start;
|
||
|
||
Field {
|
||
width: 108px;
|
||
y: (parent.height - self.height) / 2;
|
||
text: root.range-from;
|
||
placeholder: "YYYY-MM-DD";
|
||
accepted(t) => { root.range-edited(t, root.range-to); }
|
||
}
|
||
|
||
Caption {
|
||
text: "to";
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
Field {
|
||
width: 108px;
|
||
y: (parent.height - self.height) / 2;
|
||
text: root.range-to;
|
||
placeholder: "YYYY-MM-DD";
|
||
accepted(t) => { root.range-edited(root.range-from, t); }
|
||
}
|
||
|
||
if root.range-invalid: Caption {
|
||
text: "not a date";
|
||
warn: true;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- progress -----------------------------------------------------
|
||
//
|
||
// The bar that used to sit here is now the shell's, drawn across the
|
||
// top of every view from the activity register (see `activity.rs`).
|
||
// Two reasons it moved. It only ever knew about the three things the
|
||
// grid happens to report — a download running while the user was in
|
||
// develop drew nothing anywhere — and a second bar here would now say
|
||
// the same thing twice, one line apart.
|
||
//
|
||
// The grid keeps its *words*: "indexing 4000 / 17000" in the header
|
||
// above says which work is running, which a bar cannot.
|
||
|
||
// --- pinning ------------------------------------------------------
|
||
//
|
||
// Its own line, with the count spelled out: the shell's bar says that
|
||
// something is transferring, and this says how much of what.
|
||
if root.pin-total > 0: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Caption {
|
||
text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
ProgressBar {
|
||
fraction: root.pin-done / max(1, root.pin-total);
|
||
y: (parent.height - self.height) / 2;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- offline ------------------------------------------------------
|
||
//
|
||
// Above the scan error, and it suppresses it: when the server is
|
||
// unreachable the scan failure is a *consequence*, and showing both
|
||
// reports one problem twice while implying two.
|
||
if root.offline: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Caption {
|
||
text: "Offline — showing what is stored on this device"
|
||
+ (root.offline-since != "" ? " (" + root.offline-since + ")" : "");
|
||
warn: true;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
|
||
// The transport's own words. Usually specific enough to act on
|
||
// — "connection refused" and "dns error" send the user to
|
||
// different places — where a bare "offline" leaves them
|
||
// guessing whether it is their wifi or the server.
|
||
Caption {
|
||
text: root.offline-reason;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
Button {
|
||
text: "Retry";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.retry-connection(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- error --------------------------------------------------------
|
||
if root.scan-error != "" && !root.offline: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
Caption {
|
||
text: root.scan-error;
|
||
warn: true;
|
||
horizontal-alignment: center;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// --- body: sidebar beside the grid --------------------------------
|
||
//
|
||
// The capture-time axis is furniture, not a strip under the images: a
|
||
// scroll position is relative, a date is absolute, and this is the
|
||
// primary way of moving through the library.
|
||
HorizontalLayout {
|
||
vertical-stretch: 1;
|
||
|
||
// Always present, never conditional on having bars.
|
||
//
|
||
// Creating it on `timeline.length > 0` made the sidebar's 96px
|
||
// appear the moment the first dates were recorded, which narrowed
|
||
// the grid — changing `columns` and `viewport-cells`, both of which call
|
||
// back into Rust to reload the window. The first sweep flush
|
||
// therefore landed a reload storm on top of the initial thumbnail
|
||
// batch. Reserving the column costs 96px on an undated library and
|
||
// keeps the grid's width stable while dates arrive.
|
||
//
|
||
// An empty `bars` already renders as bare furniture: the `for`
|
||
// loops produce nothing and the gestures index an empty array only
|
||
// under a pointer that has no bar to land on.
|
||
Timeline {
|
||
bars: root.timeline;
|
||
range-label: root.timeline-label;
|
||
current-start: root.current-bucket;
|
||
current-fraction: root.current-bucket-fraction;
|
||
anchored: root.timeline-anchored;
|
||
range-from: root.range-from-fraction;
|
||
range-to: root.range-to-fraction;
|
||
|
||
range-changed(a, b) => { root.timeline-range-changed(a, b); }
|
||
scrub-to(f) => { root.scrub-fraction(f); }
|
||
pinch(r) => { root.timeline-pinch(r); }
|
||
pan(d) => { root.timeline-pan(d); }
|
||
zoom(d) => { root.timeline-zoom(d); }
|
||
}
|
||
|
||
grid-area := VerticalLayout {
|
||
horizontal-stretch: 1;
|
||
|
||
// --- empty state --------------------------------------------------
|
||
//
|
||
// "Still scanning" and "scanned, found nothing" are different answers.
|
||
// Conflating them is how a working scan looks broken.
|
||
//
|
||
// And "still opening" is a third, which is the state a launch is in
|
||
// for as long as it takes to read and check the catalog on this
|
||
// device. It used to be spent before the window had painted at all,
|
||
// where it read as a frozen application — on Android, as an ANR.
|
||
// Now it is a worker, so it needs a sentence: a grid saying
|
||
// "Scanning…" while nothing is on the network is the same kind of
|
||
// lie the two answers below were separated to avoid.
|
||
// An empty trash is the ordinary state of a trash, and telling
|
||
// someone who opened it to check their folder and formats sends
|
||
// them off to fix a library that is fine.
|
||
if root.total == 0: EmptyState {
|
||
headline: root.opening
|
||
? "Opening the library…"
|
||
: (root.scanning ? "Scanning…"
|
||
: (root.viewing-trash ? "The trash is empty" : "No images found"));
|
||
detail: (root.opening || root.scanning)
|
||
? root.scan-status
|
||
: (root.viewing-trash
|
||
? "Photographs you delete wait here until the trash is emptied."
|
||
: "Check the library folder and which formats are ticked.");
|
||
}
|
||
|
||
// --- keyboard judgement (FR-CULL-4) -------------------------------
|
||
//
|
||
// `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag.
|
||
// These are the keys every culling tool uses, and muscle memory
|
||
// built elsewhere is worth more here than any improvement.
|
||
//
|
||
// Zero-height rather than wrapping the grid: a FocusScope in this
|
||
// layout would claim a slot and push the grid up, and one *around*
|
||
// the Flickable competes with it for the arrow keys. This holds
|
||
// focus and forwards nothing else.
|
||
//
|
||
// Applies to the cell under the pointer when that cell is outside
|
||
// the selection, and to the **selection** otherwise — darktable's
|
||
// rule. Hovering one of forty selected frames still rates all
|
||
// forty in one keystroke; hovering a frame nobody selected rates
|
||
// that frame without disturbing the selection. Rust decides, from
|
||
// `hover-ordinal`.
|
||
judge-keys := FocusScope {
|
||
// TRACES: FR-UI-5
|
||
// The F chord: held, and the digit that arrived first while
|
||
// it was, or -1. See `key-released` and the digits below.
|
||
property <bool> f-held: false;
|
||
property <int> f-first: -1;
|
||
property <bool> f-used: false;
|
||
|
||
key-released(event) => {
|
||
// Tapping F on its own clears the star filter; having
|
||
// tapped digits under it, letting go only ends the chord.
|
||
if (self.f-held && (event.text == "f" || event.text == "F")) {
|
||
if (!self.f-used) {
|
||
root.filter-rating-range(0, 5);
|
||
}
|
||
self.f-held = false;
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
|
||
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) => {
|
||
// Declined, not swallowed, so Escape reaches the shell
|
||
// and closes the sheet rather than judging under it.
|
||
if (root.sheet-open) {
|
||
return reject;
|
||
}
|
||
// 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.labelling) {
|
||
root.labelling = false;
|
||
return accept;
|
||
}
|
||
if (root.filing) {
|
||
root.filing = false;
|
||
return accept;
|
||
}
|
||
if (root.select-mode) {
|
||
root.toggle-select-mode();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
// GESTURE: Export the selection as the last export was
|
||
// where: Library grid
|
||
// touch: Select them, then Export in the selection bar
|
||
// pointer: Select them, then Export in the selection bar
|
||
// keys: Ctrl+Shift+E, or Ctrl+E to see the export
|
||
// settings first
|
||
// why: Lightroom's and darktable's chords. Every
|
||
// export runs on the saved defaults, so the
|
||
// plain chord opens them beside an Export
|
||
// button and the shifted one skips straight to
|
||
// exporting.
|
||
//
|
||
// TRACES: FR-EXP-7 | FR-UI-5
|
||
// Before the bare keys below: a chord must not also be read
|
||
// as the letter it is spelled with.
|
||
if (event.modifiers.control
|
||
&& (event.text == "e" || event.text == "E")) {
|
||
if (event.modifiers.shift) {
|
||
root.export-selection();
|
||
} else {
|
||
root.open-export-options();
|
||
}
|
||
return accept;
|
||
}
|
||
// GESTURE: Paste copied settings onto the selection
|
||
// where: Library grid
|
||
// touch: Select them, then "Paste to N" in the
|
||
// selection bar
|
||
// pointer: Select them, then "Paste to N"
|
||
// keys: Ctrl+V
|
||
//
|
||
// TRACES: FR-DEV-6 | FR-UI-5
|
||
// Silent with nothing copied, as the button is disabled.
|
||
if (event.modifiers.control && root.settings-armed
|
||
&& (event.text == "v" || event.text == "V")) {
|
||
root.paste-settings-to-selection();
|
||
return accept;
|
||
}
|
||
// TRACES: FR-CAT-5 | FR-UI-5
|
||
// Ctrl+A takes everything the grid shows, as "Select all"
|
||
// in the selection strip does.
|
||
if (event.modifiers.control
|
||
&& (event.text == "a" || event.text == "A")) {
|
||
root.select-all();
|
||
return accept;
|
||
}
|
||
// GESTURE: Show only photographs with some number of stars
|
||
// where: Library grid
|
||
// touch: Tap a star chip in the filter bar
|
||
// pointer: Click a star chip in the filter bar
|
||
// keys: Hold F and tap a digit for exactly that many
|
||
// stars, or two digits for everything between
|
||
// them; tap F alone to show every rating again
|
||
// why: The chips say "this many or more". A range
|
||
// with a ceiling — the twos and threes still
|
||
// to be decided — is the keyboard's alone, and
|
||
// the bar says so in words while it holds.
|
||
//
|
||
// Held rather than typed in sequence, so nothing waits
|
||
// to find out whether a second digit is coming: every
|
||
// digit filters as it lands. Any other key ends the chord,
|
||
// in case its release went to a window that had focus by
|
||
// then and the grid never heard it.
|
||
if (!event.modifiers.control
|
||
&& (event.text == "f" || event.text == "F")) {
|
||
// Key repeat while held says nothing new.
|
||
if (!self.f-held) {
|
||
self.f-held = true;
|
||
self.f-first = -1;
|
||
self.f-used = false;
|
||
}
|
||
return accept;
|
||
}
|
||
if (self.f-held) {
|
||
if (event.text == "0" || event.text == "1"
|
||
|| event.text == "2" || event.text == "3"
|
||
|| event.text == "4" || event.text == "5") {
|
||
if (self.f-first < 0) {
|
||
self.f-first = event.text.to-float();
|
||
root.filter-rating-range(self.f-first, self.f-first);
|
||
} else {
|
||
root.filter-rating-range(self.f-first,
|
||
event.text.to-float());
|
||
}
|
||
self.f-used = true;
|
||
return accept;
|
||
}
|
||
self.f-held = false;
|
||
}
|
||
if (event.text == "0") { root.judged(0, -1, root.hover-ordinal); return accept; }
|
||
if (event.text == "1") { root.judged(1, -1, root.hover-ordinal); return accept; }
|
||
if (event.text == "2") { root.judged(2, -1, root.hover-ordinal); return accept; }
|
||
if (event.text == "3") { root.judged(3, -1, root.hover-ordinal); return accept; }
|
||
if (event.text == "4") { root.judged(4, -1, root.hover-ordinal); return accept; }
|
||
if (event.text == "5") { root.judged(5, -1, root.hover-ordinal); return accept; }
|
||
// GESTURE: Give photographs a colour label
|
||
// where: Library grid
|
||
// touch: Select them, then Label in the selection bar
|
||
// and tap a colour
|
||
// pointer: Select them, then Label in the selection bar
|
||
// and click a colour
|
||
// keys: 6 red, 7 yellow, 8 green, 9 blue, with the
|
||
// pointer over it or on the selection; the
|
||
// same key again takes the label off
|
||
// why: Lightroom's keys, so hands that learned them
|
||
// there need not learn them again. Purple has
|
||
// no key there either, and is on the bar. Every
|
||
// mark carries its label's initial, so the
|
||
// label is read without telling the colours
|
||
// apart.
|
||
//
|
||
// TRACES: FR-CAT-5 | FR-UI-5 | NFR-A11Y-3
|
||
// After the F chord above, which has returned for its own
|
||
// digits (0-5 only), so these never filter.
|
||
if (event.text == "6") { root.labelled(1, root.hover-ordinal); return accept; }
|
||
if (event.text == "7") { root.labelled(2, root.hover-ordinal); return accept; }
|
||
if (event.text == "8") { root.labelled(3, root.hover-ordinal); return accept; }
|
||
if (event.text == "9") { root.labelled(4, root.hover-ordinal); 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, root.hover-ordinal);
|
||
return accept;
|
||
}
|
||
if (event.text == "x" || event.text == "X") {
|
||
root.judged(-1, 2, root.hover-ordinal);
|
||
return accept;
|
||
}
|
||
if (event.text == "u" || event.text == "U") {
|
||
root.judged(-1, 0, root.hover-ordinal);
|
||
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.
|
||
// TRACES: FR-UI-4 | FR-UI-5
|
||
// F1 opens the reference the Help button does — the help
|
||
// key everywhere else.
|
||
if (event.text == Key.F1) {
|
||
root.helping = true;
|
||
return accept;
|
||
}
|
||
if (event.text == Key.F2) {
|
||
root.rename-scope();
|
||
return accept;
|
||
}
|
||
|
||
// --- walking the grid ---------------------------------
|
||
//
|
||
// The keys that make a cull possible without the mouse:
|
||
// arrows move the cursor, shift extends the selection from
|
||
// the anchor, `Return` opens what the cursor is on. The
|
||
// judgement keys above act on the selection, so walking
|
||
// with the arrows and rating as you go is one hand's work.
|
||
//
|
||
// Every one of these is `accept`ed. The `Flickable` scrolls
|
||
// on arrow keys of its own accord, and letting it would
|
||
// move the view out from under a cursor that had not
|
||
// moved — the grid is scrolled *to* the cursor instead,
|
||
// and only when the cursor leaves the viewport.
|
||
//
|
||
// The vertical steps are expressed in columns and rows,
|
||
// which only the grid knows: how far "down" is depends on
|
||
// how wide the window happens to be.
|
||
if (event.text == Key.LeftArrow) {
|
||
root.move-cursor(-1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.RightArrow) {
|
||
root.move-cursor(1, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.UpArrow) {
|
||
root.move-cursor(-root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.DownArrow) {
|
||
root.move-cursor(root.columns, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageUp) {
|
||
root.move-cursor(-root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.PageDown) {
|
||
root.move-cursor(root.columns * root.visible-rows,
|
||
event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
// A step longer than the library, clamped at the far end.
|
||
// `total` is what the grid was told the library holds, so
|
||
// this stays honest as it grows.
|
||
if (event.text == Key.Home) {
|
||
root.move-cursor(-root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.End) {
|
||
root.move-cursor(root.total, event.modifiers.shift);
|
||
return accept;
|
||
}
|
||
if (event.text == Key.Return) {
|
||
root.open-cursor();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// --- the grid -----------------------------------------------------
|
||
//
|
||
// **Scrolls until a photograph has been picked up, and not after.**
|
||
//
|
||
// `DragArea` and `Flickable` do arbitrate, but not evenly: the
|
||
// Flickable claims any press that travels more than eight pixels
|
||
// along its own axis within half a second of landing, and it holds
|
||
// that claim until the finger lifts. That is right for the ordinary
|
||
// case — a finger that moves is almost always scrolling — and it is
|
||
// why dragging the background still flicks the grid.
|
||
//
|
||
// It is wrong once the user has said otherwise. `held-row` is that
|
||
// saying: a press that has stayed put long enough to be a pick-up,
|
||
// marked on the cell so the user can see it. From there this stops
|
||
// being interactive and the drag has nothing left to lose to.
|
||
//
|
||
// Today the hold outlasts the Flickable's window anyway, so this
|
||
// mostly makes an accident into a guarantee — the arbitration stops
|
||
// depending on two constants in different crates staying in the
|
||
// order they happen to be in. The wheel is unaffected: `interactive`
|
||
// does not gate it.
|
||
if root.total > 0: grid-scroll := Flickable {
|
||
interactive: root.held-row < 0;
|
||
// See `hover-ordinal`: the cell under a still pointer changes
|
||
// as this moves, and nothing re-announces the hover.
|
||
changed viewport-y => { root.hover-ordinal = -1; }
|
||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||
// falls through to the Flickable's own scrolling. Two jobs on
|
||
// one gesture, distinguished by the modifier — the convention
|
||
// every image browser uses.
|
||
//
|
||
// Declared *first* so it sits beneath the cells in z-order:
|
||
// their own touch areas still take clicks and drags, and only
|
||
// a wheel event nothing else claimed reaches this.
|
||
zoom-catcher := TouchArea {
|
||
width: 100%;
|
||
height: parent.viewport-height;
|
||
scroll-event(e) => {
|
||
if (e.modifiers.control) {
|
||
root.zoom-cells(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// Two-finger pinch, for tablet: the same gesture the timeline
|
||
// uses, applied to cell size rather than to time.
|
||
//
|
||
// Sized to the **viewport**, like `zoom-catcher` above and for
|
||
// the same reason. `100%` inside a Flickable is the Flickable's
|
||
// own height, and the pinch is delivered to whatever lies under
|
||
// the midpoint of the two fingers — so a handler one screenful
|
||
// tall sat at the top of a viewport thousands of rows long and
|
||
// was under the fingers only while the grid had not been
|
||
// scrolled. Anywhere else the gesture found nothing to land on.
|
||
// GESTURE: Resize the thumbnails
|
||
// where: Library grid
|
||
// touch: Pinch the grid with two fingers
|
||
// pointer: Ctrl and the scroll wheel
|
||
// why: There is no wheel on a tablet, so without the
|
||
// pinch the cell size could only be changed by a
|
||
// control a finger cannot reach.
|
||
grid-pinch := ScaleRotateGestureHandler {
|
||
width: 100%;
|
||
height: parent.viewport-height;
|
||
|
||
property <float> last-scale: 1.0;
|
||
|
||
started => {
|
||
self.last-scale = 1.0;
|
||
root.pinching = true;
|
||
settle.running = false;
|
||
// The finger that opened this gesture landed on a cell
|
||
// and selected it. It was reaching for the grid, not
|
||
// for that photograph.
|
||
root.pinch-started();
|
||
}
|
||
// Continuous, not stepped. Thresholding this into ±1 zoom
|
||
// steps meant the grid lurched 25% at a time and sat still
|
||
// in between, which is the whole of "pinching is not
|
||
// smooth". The ratio since the last update is what tracks
|
||
// the fingers; where the drawn cell lands is still a whole
|
||
// number of columns, because the columns divide the width.
|
||
updated => {
|
||
root.pinch-cells(self.scale / max(0.01, self.last-scale));
|
||
self.last-scale = self.scale;
|
||
}
|
||
// The latch outlives the gesture — see `pinching`.
|
||
ended => {
|
||
self.last-scale = 1.0;
|
||
settle.running = true;
|
||
}
|
||
cancelled => {
|
||
self.last-scale = 1.0;
|
||
settle.running = true;
|
||
}
|
||
}
|
||
|
||
// Follow a requested position. Without this a scrub moves the
|
||
// *loaded window* while the viewport stays where it was, so
|
||
// the cells are drawn thousands of rows away and the grid
|
||
// looks empty until the user scrolls to find them.
|
||
function seek() {
|
||
self.viewport-y = -min(
|
||
max(0px, self.viewport-height - self.height),
|
||
floor(root.scroll-to / max(1, root.columns))
|
||
* (root.cell-size + Theme.gap));
|
||
}
|
||
|
||
property <int> token: root.scroll-token;
|
||
changed token => { self.seek(); }
|
||
|
||
// Keep the keyboard cursor in view, moving as little as will
|
||
// do it.
|
||
//
|
||
// Deliberately *not* `seek()`: that puts the requested row at
|
||
// the top, which is right for a scrub — the user asked to go
|
||
// to a date and expects to arrive there — and wrong for an
|
||
// arrow key, where the grid jumping a row upward on every
|
||
// press makes the row impossible to read. So a cursor already
|
||
// on screen moves nothing at all, and one that has just left
|
||
// brings in exactly its own row.
|
||
property <length> pitch: root.cell-size + Theme.gap;
|
||
property <int> cursor-row: floor(root.cursor / max(1, root.columns));
|
||
changed cursor-row => { self.reveal(); }
|
||
|
||
function reveal() {
|
||
if (root.cursor < 0) {
|
||
return;
|
||
}
|
||
// Nothing to reveal *into* before layout has run, and this
|
||
// is now called from `init` as well as on a cursor move —
|
||
// where a height of zero would make every row look off
|
||
// screen and scroll the grid to the cursor when `seek` had
|
||
// just put it somewhere deliberate.
|
||
if (self.height <= 0px) {
|
||
return;
|
||
}
|
||
self.revealed = true;
|
||
let top = Theme.gap + self.cursor-row * self.pitch;
|
||
let shown = -self.viewport-y;
|
||
let bottom = max(0px, self.viewport-height - self.height);
|
||
if (top < shown) {
|
||
self.viewport-y = -min(bottom, top);
|
||
} else if (top + self.pitch > shown + self.height) {
|
||
self.viewport-y = -min(bottom, top + self.pitch - self.height);
|
||
}
|
||
}
|
||
|
||
/// Whether a reveal has run against real geometry yet.
|
||
///
|
||
/// The safety net for the deferral above, and deliberately a
|
||
/// latch rather than a plain `changed height` handler: the first
|
||
/// height a rebuilt grid is given must bring the cursor into
|
||
/// view, and every height *after* that is a window being
|
||
/// resized, where dragging a corner should not keep hauling the
|
||
/// viewport back to a cursor the user is not looking at.
|
||
property <bool> revealed: false;
|
||
changed height => {
|
||
if (!self.revealed) {
|
||
self.reveal();
|
||
}
|
||
}
|
||
|
||
// Also on creation, which is what returning from the develop
|
||
// view needs. The grid is gated on an `if`, so it is built anew
|
||
// and `token` is *initialised* to the already-bumped value
|
||
// rather than changing to it — no `changed` handler fires, and
|
||
// without this the restored position would be dropped and the
|
||
// view would sit at the top.
|
||
//
|
||
// `reveal()` after `seek()`, and the order is the point: seek
|
||
// puts the viewport where the grid was left, reveal then moves
|
||
// it as little as will bring the cursor's row into view. Coming
|
||
// back from develop the cursor is on the photograph that was
|
||
// open, so a frame inside the remembered screenful moves
|
||
// nothing at all and one outside it is scrolled just far enough
|
||
// to be seen. `cursor-row` is *initialised* here too, so its own
|
||
// `changed` handler never fires and this is the only thing that
|
||
// can do it.
|
||
init => { self.seek(); self.reveal(); }
|
||
|
||
// Sized to the **whole library**, not the loaded window. The
|
||
// scrollbar has to represent 23,971 images or there is no way to
|
||
// reach image 20,000 — dragging it must be a real address, and the
|
||
// window is swapped underneath to match.
|
||
// Plus room for the selection bar, which floats over the
|
||
// foot of the grid rather than sitting above it. Extending the
|
||
// viewport rather than shrinking the Flickable is what keeps
|
||
// the change invisible: every cell stays exactly where it was
|
||
// and there is simply further to scroll, so the last row can be
|
||
// brought clear of the bar instead of being trapped under it.
|
||
// Same condition as the bar itself, or a running export with
|
||
// the selection since cleared would leave the last row of
|
||
// thumbnails trapped under a bar the grid did not know was
|
||
// there.
|
||
property <length> selection-inset:
|
||
(root.selected-count > 0 || root.exporting) ? 40px : 0px;
|
||
|
||
viewport-height: root.total-rows * (root.cell-size + Theme.gap)
|
||
+ Theme.gap + self.selection-inset;
|
||
|
||
// TRACES: FR-CAT-7
|
||
// Stay inside the content when the content shrinks.
|
||
//
|
||
// Deleting photographs makes the library shorter, and the
|
||
// viewport is sized to the *whole* library — so a view that was
|
||
// scrolled near the end is suddenly scrolled past it. Slint
|
||
// does not pull a Flickable back on its own, so the grid went
|
||
// blank: the cells were still there, above a viewport looking
|
||
// at empty space below them.
|
||
//
|
||
// Worse than blank, it also *moved*. Cells are drawn at
|
||
// `(i + offset) / columns`, and a delete re-clamps `offset`
|
||
// downward to keep the window full — so the same `viewport-y`
|
||
// now points at a different part of the library, and the grid
|
||
// appeared to jump somewhere arbitrary. Re-seeking below is the
|
||
// other half of this; this half stops the blank.
|
||
changed viewport-height => {
|
||
if (-self.viewport-y > max(0px, self.viewport-height - self.height)) {
|
||
self.viewport-y = -max(0px, self.viewport-height - self.height);
|
||
}
|
||
}
|
||
|
||
// Report the first fully-scrolled-past row so Rust can move the
|
||
// window. Derived rather than eventful: Slint has no scroll
|
||
// callback, and a `changed` handler on a derived integer fires only
|
||
// when the row actually changes rather than on every pixel.
|
||
property <int> first-visible-row: max(0,
|
||
floor((-self.viewport-y - Theme.gap) / (root.cell-size + Theme.gap)));
|
||
changed first-visible-row => {
|
||
root.scrolled(self.first-visible-row * root.columns);
|
||
}
|
||
|
||
// Month headings, drawn over the grid at the row where each
|
||
// period begins. A separate pass rather than part of the cell,
|
||
// because the heading spans the full width and a cell does not.
|
||
for cell[i] in root.cells: Text {
|
||
x: Theme.gap;
|
||
// Sits in the gap above its row, so it labels the row
|
||
// rather than displacing it.
|
||
y: Theme.gap
|
||
+ floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap)
|
||
- 15px;
|
||
width: parent.width - 2 * Theme.gap;
|
||
text: cell.period-heading;
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-sm;
|
||
font-weight: 700;
|
||
visible: cell.period-heading != "";
|
||
}
|
||
|
||
// GESTURE: File photographs in a collection
|
||
// where: Library grid
|
||
// touch: Drag a photograph — or a whole selection — onto a
|
||
// collection in the sidebar. Starting a drag stops
|
||
// the press becoming a hold, so it cannot leave you
|
||
// in selection mode.
|
||
// pointer: Drag a photograph — or a whole selection — onto a
|
||
// collection in the sidebar
|
||
// why: The selection is what the drag carries, which is
|
||
// why selecting several is worth the mode: forty
|
||
// photographs file in one gesture.
|
||
for cell[i] in root.cells: DragArea {
|
||
// Cells are positioned at their **absolute** place in the
|
||
// library, not their index in the loaded window: the window
|
||
// starts at `offset`, so a cell drawn at window-index 0 belongs
|
||
// wherever `offset` sits in the full grid.
|
||
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap);
|
||
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap);
|
||
width: root.cell-size;
|
||
height: root.cell-size;
|
||
|
||
// Copy, not move: dropping into a collection files the
|
||
// photograph there without taking it out of anywhere else. That
|
||
// is what a join table means, and it is why the modifier-free
|
||
// gesture must not be `move`.
|
||
allow-copy: true;
|
||
// Not the payload — the arming. See `drag-payload`.
|
||
data: root.drag-payload();
|
||
// What travels under the cursor is the photograph itself — and
|
||
// where several are being dragged, a stack of them. Composited
|
||
// in Rust (`drag_image`), because Slint takes a single bitmap
|
||
// here and cannot render a pile of thumbnails into one.
|
||
//
|
||
// Read on `dragging` rather than bound continuously: the
|
||
// composite costs a copy per thumbnail, and the grid must not
|
||
// pay it per cell per frame.
|
||
drag-image: root.drag-image;
|
||
|
||
changed dragging => {
|
||
if (self.dragging) {
|
||
root.drag-started(i);
|
||
}
|
||
}
|
||
drag-finished(action) => { root.drag-finished(); }
|
||
|
||
// Where a reorder lands. Behind the cell's content and
|
||
// before it in the file, for the reason `TreeRow`'s drop
|
||
// area gives: a `DropArea` only takes part in a drag, so it
|
||
// does not block the presses the cell's own TouchArea
|
||
// needs — but drawn first it cannot paint over the
|
||
// thumbnail either.
|
||
//
|
||
// Present on every cell rather than wrapped in an `if`, so
|
||
// the marker below can name it. `can-drop` is where the
|
||
// refusal lives, which also means the cursor says no while
|
||
// the user can still aim somewhere else.
|
||
reorder-drop := DropArea {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
/// Whether the run would land after this photograph
|
||
/// rather than before it. Tracked during the hover so
|
||
/// the marker can move to the edge the drop will
|
||
/// actually use.
|
||
property <bool> after: false;
|
||
|
||
can-drop(ev) => {
|
||
if (!root.reorderable) {
|
||
return DragAction.none;
|
||
}
|
||
self.after = ev.position.x > self.width / 2;
|
||
return DragAction.copy;
|
||
}
|
||
|
||
dropped(ev) => {
|
||
root.reorder-to(i, ev.position.x > self.width / 2);
|
||
return DragAction.copy;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
// Lifted cells shrink toward their own centre, as though pulled
|
||
// off the page. Inset rather than scaled: Slint has no transform
|
||
// on a plain Rectangle, and insetting keeps the cell's slot in
|
||
// the grid so nothing reflows mid-drag.
|
||
x: cell.lifted ? 10px : 0px;
|
||
y: cell.lifted ? 10px : 0px;
|
||
width: parent.width - 2 * self.x;
|
||
height: parent.height - 2 * self.y;
|
||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
// Hover only. **Selection is the inset ring below**, and
|
||
// nothing out here changes when a cell is selected.
|
||
//
|
||
// It used to be a 2px border and a lifted fill, and a
|
||
// border drawn on the outside of a 6px-padded cell eats
|
||
// into the thumbnail: selecting appeared to nudge the
|
||
// photograph, which is exactly the wrong feedback for a
|
||
// gesture whose whole job is to say "this one". One
|
||
// treatment, drawn inside, in one place.
|
||
//
|
||
// **Not drawn at all once there has been a finger.** A
|
||
// hand has no hover to give, so on a tablet this ring can
|
||
// only ever be wrong — and it is wrong in the worst way,
|
||
// because it is the selection ring's own colour a pixel
|
||
// thinner. `has-hover` is not reliably cleared on touch:
|
||
// a release normally brings an `Exit` with it, but the one
|
||
// that ends a pinch does not, so every pinch to resize the
|
||
// thumbnails left a ring around whichever cell a finger
|
||
// happened to have started on. The grid then showed boxes
|
||
// around photographs that were not selected, with no way
|
||
// to tell them from ones that were. See `touched`.
|
||
border-width: cell-touch.has-hover && !root.touched ? 1px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
VerticalLayout {
|
||
padding: 6px;
|
||
spacing: 4px;
|
||
|
||
Rectangle {
|
||
vertical-stretch: 1;
|
||
background: Theme.ground;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
// Faded while lifted, so the grid reads as the place
|
||
// the photograph came *from* and the cursor as where
|
||
// it is now. `colorize` would flatten it to one
|
||
// tint, which loses the picture; dropping opacity
|
||
// toward the ground keeps it recognisable as a ghost
|
||
// of itself.
|
||
// A rejected frame is held back rather than
|
||
// hidden: the cull is reversible, and a photo
|
||
// that vanished on one keypress would make the
|
||
// gesture frightening to use. Dimming is the
|
||
// cue that reads at grid scale — the cross on
|
||
// the mark confirms it up close.
|
||
opacity: cell.lifted ? 0.25
|
||
: (cell.flag == 2 ? 0.4 : 1.0);
|
||
animate opacity { duration: 120ms; }
|
||
}
|
||
|
||
if !cell.has-thumb: Caption {
|
||
text: cell.unavailable ? "no preview" : "…";
|
||
horizontal-alignment: center;
|
||
}
|
||
|
||
// "Already filed, in this many collections." Without it
|
||
// there is no way to tell a filed photograph from an
|
||
// unfiled one, and the user re-files what is already
|
||
// in place.
|
||
if cell.collection-count > 0: Rectangle {
|
||
x: parent.width - self.width - 4px;
|
||
y: 4px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
background: Theme.selected;
|
||
opacity: 0.9;
|
||
|
||
Text {
|
||
text: cell.collection-count;
|
||
color: Theme.ink;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// Pick or reject, top left — the opposite corner
|
||
// from the collection badge so the two never
|
||
// collide on a cell that carries both.
|
||
FlagMark {
|
||
x: 4px;
|
||
y: 4px;
|
||
flag: cell.flag;
|
||
}
|
||
|
||
}
|
||
|
||
// TRACES: FR-CAT-5 | NFR-A11Y-3
|
||
// The colour label leads the caption: on the
|
||
// photograph it would compete with the flag and the
|
||
// badges for four corners, and here it is read with
|
||
// the name, in the row every cell already has.
|
||
HorizontalLayout {
|
||
spacing: 4px;
|
||
|
||
if cell.label > 0: LabelMark {
|
||
code: cell.label;
|
||
size: 14px;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
Label {
|
||
text: cell.name;
|
||
emphasised: cell.selected;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
}
|
||
|
||
// **Selection, and the only mark of it.**
|
||
//
|
||
// This ring used to mean something narrower — the end a
|
||
// shift-click measures from — and it was the clearest thing
|
||
// on the cell, so it read as the selection to everyone who
|
||
// had not written it. It also outlived what it described: an
|
||
// anchor survives a deselection, so a thin box sat around
|
||
// the last photograph touched with nothing selected at all,
|
||
// and there was no way to tell it apart from a cell that had
|
||
// stayed behind.
|
||
//
|
||
// So the ring is now what it already looked like. The anchor
|
||
// has no mark of its own, which costs nothing it was earning:
|
||
// the range gesture announces itself in the bar — "Tap the
|
||
// last photograph" — and does not need the user to find
|
||
// where it will measure from.
|
||
//
|
||
// Drawn *inside* the cell and 4px clear of the edge, so it
|
||
// never touches the thumbnail and never changes a single
|
||
// dimension. Selecting adds ink and moves nothing.
|
||
Rectangle {
|
||
visible: cell.selected;
|
||
x: 4px;
|
||
y: 4px;
|
||
width: parent.width - 8px;
|
||
height: parent.height - 8px;
|
||
background: transparent;
|
||
// Two, not one: this is now the whole of the cue, and it
|
||
// has to survive being read across forty cells at arm's
|
||
// length against a thumbnail of any brightness.
|
||
border-width: 2px;
|
||
border-color: Theme.selected-ring;
|
||
border-radius: Theme.radius-sm;
|
||
}
|
||
|
||
// Selection only. The drag is the enclosing `DragArea`'s
|
||
// business, and Slint keeps a click distinct from a drag for
|
||
// us — which is exactly the arbitration the hand-rolled version
|
||
// had to fake with a travel threshold.
|
||
cell-touch := TouchArea {
|
||
mouse-cursor: pointer;
|
||
|
||
// TRACES: FR-UI-5
|
||
// Which photograph a judgement key would reach
|
||
// without a selection. Cleared only by the cell that
|
||
// set it, so moving from one cell to the next cannot
|
||
// be undone by the old cell's leave arriving second.
|
||
changed has-hover => {
|
||
if (self.has-hover) {
|
||
root.hover-ordinal = i + root.offset;
|
||
} else if (root.hover-ordinal == i + root.offset) {
|
||
root.hover-ordinal = -1;
|
||
}
|
||
}
|
||
|
||
// Selected on *press*, not on release: the drag that may
|
||
// follow reads the selection to build its payload, and by
|
||
// release the pointer is over the sidebar.
|
||
//
|
||
// In selection mode the press is reported as though ctrl
|
||
// were held. That is not a shortcut: toggling one cell
|
||
// while keeping the rest *is* what ctrl-click means, and
|
||
// giving touch its own policy would be a second copy of
|
||
// the rules in `collections_ui::apply_press` to keep in
|
||
// step with the first.
|
||
// --- which finger, and whether it finished --------
|
||
//
|
||
// A pinch begins as an ordinary press. When the second
|
||
// finger lands Slint closes the first one's gesture by
|
||
// synthesising a `Released` at its position — that is
|
||
// how a Flickable is made to let go of a scroll it had
|
||
// already claimed. A TouchArea cannot tell that release
|
||
// from a real one and fires `clicked`, so every attempt
|
||
// to pinch-zoom the grid opened whichever photograph
|
||
// the first finger happened to be resting on.
|
||
//
|
||
// The finger id is what separates them: the synthetic
|
||
// release carries the id of the finger that *arrived*,
|
||
// never the one that pressed. A mouse reports 0 for
|
||
// both, so the desktop path is unchanged.
|
||
//
|
||
// `clicked` fires before the `up` that follows it, so
|
||
// it can only raise a flag — the decision to open has
|
||
// to wait for the event that names the finger.
|
||
// GESTURE: Open a photograph
|
||
// where: Library grid
|
||
// touch: Tap it — a single tap, any length
|
||
// pointer: Click it
|
||
// why: A tap opens; a tap that *moved* does not.
|
||
// Travel is what separates a deliberate tap
|
||
// from a hand brushing past, and it is the
|
||
// only thing that does: the two are the same
|
||
// length. An earlier version required the
|
||
// finger to dwell 120 ms instead, and that
|
||
// rejected ordinary taps — a real tap is
|
||
// often quicker than a brush.
|
||
property <int> down-finger: -1;
|
||
property <bool> click-pending: false;
|
||
|
||
// Where the press landed, so the release can tell how
|
||
// far the finger travelled.
|
||
//
|
||
// The Flickable already cancels a press that becomes a
|
||
// scroll, but only once it has claimed the gesture,
|
||
// which takes more travel than a graze has. This
|
||
// catches the rest: a contact that slid across the cell
|
||
// and lifted was reaching for something else.
|
||
property <length> press-x: 0;
|
||
property <length> press-y: 0;
|
||
|
||
/// How far a finger may slide and still be a tap.
|
||
///
|
||
/// A thumb's contact patch is wider than this, so no
|
||
/// stationary tap approaches it; a hand moving across
|
||
/// the glass passes it within one frame. Below about
|
||
/// this the number stops describing intent and starts
|
||
/// describing how steady the user's hand is.
|
||
property <length> tap-slop: 12px;
|
||
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
self.down-finger = ev.touch-finger-id;
|
||
self.click-pending = false;
|
||
self.press-x = self.mouse-x;
|
||
self.press-y = self.mouse-y;
|
||
// A finger, not a pointer — see `touched`.
|
||
if (ev.touch-finger-id != 0) {
|
||
root.touched = true;
|
||
}
|
||
// Not while the grid is being pinched, nor in
|
||
// the moment after: this is the finger left
|
||
// over from the gesture, handed back as a new
|
||
// press. See `pinching`.
|
||
if (!root.pinching) {
|
||
// An armed range reaches Rust as shift,
|
||
// which is what it is: `apply_press` reads
|
||
// ctrl+shift as "add the run from the
|
||
// anchor to here", and in selection mode
|
||
// ctrl is already set. Sent this way rather
|
||
// than as a third selection policy, so the
|
||
// rules stay in one place.
|
||
root.cell-pressed(
|
||
i,
|
||
ev.modifiers.control || root.select-mode,
|
||
ev.modifiers.shift || root.ranging,
|
||
);
|
||
// One shot. The range was between two taps
|
||
// and the second has landed; left armed, it
|
||
// would turn every tap after it into
|
||
// another run.
|
||
root.ranging = false;
|
||
}
|
||
}
|
||
if (ev.kind == PointerEventKind.up) {
|
||
// A *plain* click opens the image; a modified
|
||
// one is purely a selection gesture and must
|
||
// not navigate away from the grid the user is
|
||
// building a selection in. The modifier state
|
||
// is not carried here, so the press above
|
||
// records it and Rust decides — `cell-clicked`
|
||
// is only honoured when the press was
|
||
// unmodified.
|
||
if (self.click-pending
|
||
&& !root.pinching
|
||
&& ev.touch-finger-id == self.down-finger
|
||
&& abs(self.mouse-x - self.press-x) < self.tap-slop
|
||
&& abs(self.mouse-y - self.press-y) < self.tap-slop) {
|
||
root.cell-clicked(i);
|
||
}
|
||
self.click-pending = false;
|
||
// Down again, whether or not it was ever up.
|
||
root.held-row = -1;
|
||
root.cell-press-ended();
|
||
}
|
||
// `cancel` is the important ending: the Flickable
|
||
// takes the pointer as soon as the finger travels,
|
||
// so without this a scroll that began on a cell
|
||
// would come to rest as a long press and select it.
|
||
if (ev.kind == PointerEventKind.cancel) {
|
||
self.click-pending = false;
|
||
// Including the cancel a starting drag sends:
|
||
// by then the `DragArea` has the gesture and
|
||
// `lifted` is the mark that matters, so there
|
||
// is no scroll left to lose and nothing to
|
||
// keep the ring open for.
|
||
root.held-row = -1;
|
||
root.cell-press-ended();
|
||
}
|
||
}
|
||
|
||
clicked => { self.click-pending = true; }
|
||
// **No `double-clicked` here, deliberately.** A double
|
||
// tap used to take a range in selection mode. It was
|
||
// the only gesture touch had for shift-click, and
|
||
// "Select to…" replaced it with a control that says
|
||
// what it is about to do. Leaving both meant two quick
|
||
// taps on one cell — a thing a hand does by accident —
|
||
// silently selecting a run of forty photographs, with
|
||
// no visible state to explain where they came from.
|
||
//
|
||
// Now two taps are two toggles and land back where they
|
||
// started, which is the only thing a user can predict
|
||
// from what is on screen.
|
||
|
||
// GESTURE: Rate a photograph without opening it
|
||
// where: Library grid
|
||
// touch: Tap a star on the cell
|
||
// pointer: Hover the cell, then click a star
|
||
// keys: 0 to 5 with the pointer over it, or
|
||
// on the selection
|
||
// why: A star has to take the press without it
|
||
// also reaching the cell, or every rating
|
||
// throws the user into develop.
|
||
//
|
||
// --- the rating strip, INSIDE the cell's hit area ---
|
||
//
|
||
// **A child of `cell-touch`, not a sibling of it.** Two
|
||
// things have to be true at once here, and only this
|
||
// nesting gets both.
|
||
//
|
||
// A star must take the click without it also reaching
|
||
// `cell-clicked`, or every rating throws the user into
|
||
// develop. Children are hit-tested before the element
|
||
// they sit in, and a child that accepts ends the walk
|
||
// before the TouchArea's own handler runs — so the star
|
||
// wins, and it wins for the same reason a later sibling
|
||
// used to.
|
||
//
|
||
// And the strip must not vanish as the pointer arrives
|
||
// at it. As a *sibling* it did: hover is tracked per
|
||
// TouchArea, Slint sends `Exit` to whatever drops out of
|
||
// the hit path, and the strip taking the pointer dropped
|
||
// `cell-touch` out of it. `has-hover` went false, which
|
||
// took `show-empty` with it, which hid the very stars
|
||
// the pointer was travelling towards — on an unrated
|
||
// cell they disappeared, the click landed on the cell
|
||
// behind them, and the image opened. That is the whole
|
||
// of the "stars vanish when I click one" report.
|
||
//
|
||
// An *ancestor* stays in the path: it keeps its place on
|
||
// the item stack, gets no `Exit`, and during a grab it is
|
||
// handed only the filter and never the event. So hover
|
||
// holds for as long as the pointer is anywhere in the
|
||
// cell, stars included.
|
||
//
|
||
// It sits over the foot of the thumbnail rather than
|
||
// below it: the caption row is spoken for by the
|
||
// filename, and a third row would cost thumbnail height
|
||
// on every cell to show something that is usually empty.
|
||
StarStrip {
|
||
x: (parent.width - self.width) / 2;
|
||
// Clear of the caption, which is the cell's last row.
|
||
y: parent.height - self.height - 26px;
|
||
rating: cell.rating;
|
||
// Empty stars appear once the pointer is over the
|
||
// cell, so there is something to aim at without
|
||
// filling the grid with chrome. That trade only
|
||
// works where there is a pointer to hover with: on
|
||
// touch the strip stands open, because a control
|
||
// that appears under the finger is a control that
|
||
// appears too late to aim at. See `touched`.
|
||
show-empty: cell-touch.has-hover || root.touched;
|
||
can-trash: !root.viewing-trash;
|
||
rate(n) => { root.cell-rated(i, n); }
|
||
trash() => { root.cell-trashed(i); }
|
||
}
|
||
|
||
// The burst mark (FR-CULL-5): how many frames this
|
||
// moment holds,
|
||
// and the way in and out of them.
|
||
//
|
||
// A child of `cell-touch` for exactly the reason the
|
||
// stars above are: a click here must not also reach
|
||
// `cell-clicked` and throw the user into develop, and
|
||
// children are hit-tested before the element they sit
|
||
// in. Unlike the stars it is never hidden — a collapsed
|
||
// burst is standing in for frames that are not on
|
||
// screen, and there has to be something visible saying
|
||
// so whether or not a pointer is anywhere near.
|
||
//
|
||
// Bottom left, clear of the centred star strip and of
|
||
// both top corners, which the flag and the collection
|
||
// badge already have.
|
||
if cell.burst-count > 1: Rectangle {
|
||
x: 6px;
|
||
y: parent.height - self.height - 26px;
|
||
width: 30px;
|
||
height: 18px;
|
||
|
||
// The pile behind the top card, drawn only while the
|
||
// group is folded up. It is the whole of the "there
|
||
// is more than one of these" cue; once the burst is
|
||
// open the frames themselves say it.
|
||
Rectangle {
|
||
x: 3px;
|
||
y: -3px;
|
||
width: parent.width - 3px;
|
||
height: parent.height;
|
||
visible: !cell.burst-expanded;
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius-sm;
|
||
border-width: 1px;
|
||
border-color: Theme.rule;
|
||
}
|
||
|
||
Rectangle {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: cell.burst-expanded ? Theme.selected
|
||
: Theme.surface;
|
||
border-radius: Theme.radius-sm;
|
||
border-width: 1px;
|
||
border-color: cell.burst-expanded ? Theme.selected-ring
|
||
: Theme.rule;
|
||
|
||
Text {
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
// No "of": the number is the size of the
|
||
// group, and a cell this small cannot
|
||
// afford a word to say so.
|
||
text: cell.burst-count;
|
||
color: Theme.ink;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
|
||
TouchArea {
|
||
mouse-cursor: pointer;
|
||
clicked => { root.burst-toggled(i); }
|
||
}
|
||
}
|
||
|
||
// GESTURE: Choose the frame a folded burst shows
|
||
// where: Library grid
|
||
// touch: Open the burst, then tap the ring on the
|
||
// frame you want
|
||
// pointer: Open the burst, then click the ring on the
|
||
// frame you want
|
||
// why: A folded burst draws its earliest frame,
|
||
// which is a fact about the clock and not a
|
||
// judgement about the photograph — nothing
|
||
// in this application ranks a frame
|
||
// (FR-CULL-5). But the point of a burst is
|
||
// that one of the twelve is better than the
|
||
// other eleven, and the photographer is the
|
||
// only one who knows which. So the choice is
|
||
// offered on the frames themselves, while
|
||
// they are open and side by side, which is
|
||
// the one moment the alternatives are on
|
||
// screen to be compared.
|
||
//
|
||
// Drawn on every frame of an open group and on none of
|
||
// a folded one. Folded, the single cell showing *is*
|
||
// the representative, so the mark would state the
|
||
// obvious and the frames it offers to choose between
|
||
// would not be on screen to choose from.
|
||
//
|
||
// Bottom right, opposite the count in the other corner:
|
||
// "this many frames" at one end and "this is the one"
|
||
// at the other, clear of the flag and the collection
|
||
// badge in the top two. A slip between it and ★5 sets a
|
||
// rating — the harmless direction for an ambiguous
|
||
// press, which is why it is on this side of the cell
|
||
// and not beside the trash target.
|
||
//
|
||
// A child of `cell-touch` for the reason the stars are:
|
||
// a press here must be spent on the choice and must not
|
||
// also reach `cell-clicked` and open the photograph.
|
||
if cell.burst-expanded: Rectangle {
|
||
x: parent.width - self.width - 6px;
|
||
y: parent.height - self.height - 26px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: self.height / 2;
|
||
background: Theme.surface;
|
||
border-width: 1px;
|
||
border-color: cell.burst-representative
|
||
? Theme.selected-ring : Theme.rule;
|
||
|
||
// Ticked or empty, and that is the whole of the
|
||
// state: which frame stands for the moment is said
|
||
// by the presence of a mark rather than by a colour
|
||
// (NFR-A11Y-3), so it survives being read at grid
|
||
// scale and by an eye that does not separate the
|
||
// two hues.
|
||
if cell.burst-representative: Icon {
|
||
name: "check";
|
||
ink: Theme.active;
|
||
size: 11px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
// Live on the chosen frame too, rather than inert
|
||
// there. A disabled TouchArea lets the press fall
|
||
// through to the cell behind it, so tapping the one
|
||
// mark that is already ticked would open the
|
||
// photograph — a control that does something
|
||
// unrelated when pressed twice. Pressing it instead
|
||
// says "yes, this one", which is a thing the user
|
||
// may mean: the default is the earliest frame, and
|
||
// recording the choice keeps it through a regroup
|
||
// that finds an earlier one.
|
||
TouchArea {
|
||
// Grown past the drawn ring to FR-UI-3's
|
||
// minimum and centred on it, the way the stars
|
||
// beside it are.
|
||
width: max(parent.width, Theme.touch-target);
|
||
height: max(parent.height, Theme.touch-target);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
mouse-cursor: pointer;
|
||
clicked => { root.burst-representative-chosen(i); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Where the run would land. A bar in the gutter beside the
|
||
// cell it would sit next to, on whichever side the drop
|
||
// will actually use — the trailing edge is what makes the
|
||
// last place in a collection reachable, and a marker that
|
||
// did not move with it would be pointing at the wrong gap
|
||
// half the time.
|
||
//
|
||
// Last child of the `DragArea`, so it draws over the
|
||
// thumbnail rather than under it. In the gutter rather than
|
||
// on the cell, because a bar drawn *on* the first cell of a
|
||
// row reads as belonging to that cell instead of to the
|
||
// space before it.
|
||
if reorder-drop.has-drag: Rectangle {
|
||
x: reorder-drop.after
|
||
? parent.width + Theme.gap / 2 - 1.5px
|
||
: -Theme.gap / 2 - 1.5px;
|
||
y: 0;
|
||
width: 3px;
|
||
height: parent.height;
|
||
background: Theme.active;
|
||
border-radius: 1.5px;
|
||
}
|
||
}
|
||
|
||
// **The photograph is in your hand now.**
|
||
//
|
||
// A ring that opens outward around the cell a press has held
|
||
// long enough to pick up (see `held-row`), so the wait the
|
||
// gesture needs has something to end in. Without it the user
|
||
// is holding a finger on glass with no way to know whether
|
||
// anything has happened — which is what made dragging feel
|
||
// like a coin toss.
|
||
//
|
||
// **Drawn after the cells, not on one.** Cells are one `for`,
|
||
// and z-order inside a `for` is the loop order — a cell that
|
||
// grew past its bounds would stand over its left and top
|
||
// neighbours and be cut off by its right and bottom ones,
|
||
// which reads as a rendering fault rather than as a lift. One
|
||
// element after the loop is above every cell by construction,
|
||
// and there is only ever one photograph in the hand.
|
||
//
|
||
// `visible` rather than `if`, so it has somewhere to animate
|
||
// *from*: an `if` builds the ring at its final size and it
|
||
// would appear rather than open.
|
||
//
|
||
// `Theme.active` and 3px, deliberately unlike the selection
|
||
// ring inside the cell — two marks that meant different things
|
||
// in one colour is the mistake the hover ring made.
|
||
Rectangle {
|
||
property <length> pitch: root.cell-size + Theme.gap;
|
||
property <length> reach: root.held-row >= 0 ? 5px : 0px;
|
||
|
||
visible: root.held-row >= 0;
|
||
x: Theme.gap
|
||
+ mod(root.held-row + root.offset, root.columns) * self.pitch
|
||
- self.reach;
|
||
y: Theme.gap
|
||
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
|
||
- self.reach;
|
||
width: root.cell-size + 2 * self.reach;
|
||
height: root.cell-size + 2 * self.reach;
|
||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||
|
||
background: transparent;
|
||
border-width: 3px;
|
||
border-color: Theme.active;
|
||
border-radius: Theme.radius;
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// --- the filing sheet (FR-CAT-7, FR-UI-4) -------------------------------
|
||
//
|
||
// "Put these in…", for the times a drag is not available: one finger on a
|
||
// scrolling grid, or a selection made across a scrub where the sidebar has
|
||
// long since been closed to give the photographs the width.
|
||
//
|
||
// Last in the file, so it draws over the grid — and outside the
|
||
// VerticalLayout above, so appearing does not reflow the header and the
|
||
// cells underneath it.
|
||
if root.filing: Rectangle {
|
||
background: #000000CC;
|
||
|
||
// Swallows the taps that miss the card, and closes. First, so the
|
||
// card's own controls sit above it.
|
||
TouchArea {
|
||
clicked => { root.filing = false; }
|
||
}
|
||
|
||
Rectangle {
|
||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||
// Tall enough for the list, but never taller than the window: a
|
||
// library with forty collections must still leave the buttons on
|
||
// screen, which is what the Flickable inside is for.
|
||
height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
border-width: 1px;
|
||
border-color: Theme.rule;
|
||
|
||
// Stops a press on the card reaching the scrim behind it.
|
||
TouchArea { }
|
||
|
||
sheet := VerticalLayout {
|
||
padding: Theme.gap-lg;
|
||
spacing: Theme.gap;
|
||
|
||
Text {
|
||
text: root.selected-count == 1
|
||
? "File 1 photograph in…"
|
||
: "File " + root.selected-count + " photographs in…";
|
||
color: Theme.ink;
|
||
font-size: Theme.text-lg;
|
||
font-weight: 600;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// Filing is a *copy*: a photograph can be in as many
|
||
// collections as it belongs in, which is what a join table
|
||
// means and what the drag has always done. Moving is the
|
||
// exception and has to be asked for, because it is the one
|
||
// that takes something away.
|
||
if root.scope-label != "": Button {
|
||
text: root.filing-moves
|
||
? "Moving out of " + root.scope-label
|
||
: "Also keep in " + root.scope-label;
|
||
active: root.filing-moves;
|
||
clicked => { root.filing-moves = !root.filing-moves; }
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
// A floor, so the list is not squeezed out of existence by
|
||
// the buttons around it on a short window.
|
||
min-height: 120px;
|
||
viewport-height: root.collections.length * (Theme.touch-target + 2px);
|
||
|
||
for row[i] in root.collections: Rectangle {
|
||
y: i * (Theme.touch-target + 2px);
|
||
width: parent.width;
|
||
// A full touch target per row, where the sidebar's
|
||
// equivalent is 26px. The sidebar is a place to look;
|
||
// this is a place to hit once, with a thumb, holding a
|
||
// selection that took a minute to build (FR-UI-3).
|
||
height: Theme.touch-target;
|
||
background: row-touch.pressed ? Theme.pressed
|
||
: (row-touch.has-hover ? Theme.hover : transparent);
|
||
border-radius: Theme.radius-sm;
|
||
// A saved filter's membership is its selector, so it
|
||
// cannot be filed into — the same refusal the sidebar
|
||
// makes on a drag, made here before the tap rather
|
||
// than after it.
|
||
opacity: row.smart ? 0.4 : 1.0;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap-sm + row.depth * Theme.indent;
|
||
padding-right: Theme.gap-sm;
|
||
spacing: Theme.gap-sm;
|
||
|
||
Icon {
|
||
name: row.smart ? "collection-smart" : "collection";
|
||
ink: Theme.ink-faint;
|
||
size: 14px;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
|
||
Text {
|
||
text: row.name;
|
||
color: Theme.ink;
|
||
font-size: Theme.text;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
Text {
|
||
text: row.smart ? "computed"
|
||
: (row.deep-count > 0 ? row.deep-count + "" : "");
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
row-touch := TouchArea {
|
||
enabled: !row.smart;
|
||
clicked => {
|
||
root.file-in-collection(row.id, root.filing-moves);
|
||
root.filing = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Points at the button below rather than at the sidebar's
|
||
// `+`. On a tablet the sidebar is instantiated but not
|
||
// drawn, so the old text sent the user to look for a
|
||
// control that was not on the screen — and this sheet is
|
||
// now the one place that can make a collection without
|
||
// one.
|
||
if root.collections.length == 0: Text {
|
||
text: "No collections yet — make the first one below.";
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
wrap: word-wrap;
|
||
width: parent.width;
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
Button {
|
||
text: "Cancel";
|
||
horizontal-stretch: 1;
|
||
clicked => { root.filing = false; }
|
||
}
|
||
|
||
// Filing a selection into a collection that does not exist
|
||
// yet took four steps: make a collection, find it, select
|
||
// the photographs again, add them. One press instead,
|
||
// which is how a selection is usually meant.
|
||
//
|
||
// Here rather than on the bar because it is the answer to
|
||
// a question this sheet has just asked and failed to
|
||
// answer: the list above is every collection there is, and
|
||
// this is what you press when none of them is the one.
|
||
//
|
||
// The naming sheet replaces this one rather than stacking
|
||
// over it — two scrims deep is a place where dismissing
|
||
// once looks like it did nothing.
|
||
Button {
|
||
text: "New collection…";
|
||
primary: true;
|
||
horizontal-stretch: 1;
|
||
clicked => {
|
||
root.filing = false;
|
||
root.naming = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the keywording sheet (FR-CAT-5, FR-CAT-6) --------------------------
|
||
//
|
||
// "These are of…". Deliberately the same card, scrim and dismissal as the
|
||
// filing sheet above: a user who has filed a selection already knows how
|
||
// this works, and a second idiom for the same gesture would be a second
|
||
// thing to learn for no gain.
|
||
//
|
||
// It stays open after each word, where the filing sheet closes. Filing is
|
||
// one choice; keywording is usually several — "puffin", "Látrabjarg",
|
||
// "2026" — and a sheet that shut after each one would have to be reopened,
|
||
// and the selection re-confirmed, three times over.
|
||
if root.keywording: Rectangle {
|
||
background: #000000CC;
|
||
|
||
// Swallows the taps that miss the card, and closes. First, so the
|
||
// card's own controls sit above it.
|
||
TouchArea {
|
||
clicked => { root.keywording = false; }
|
||
}
|
||
|
||
Rectangle {
|
||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||
height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
border-width: 1px;
|
||
border-color: Theme.rule;
|
||
|
||
// Stops a press on the card reaching the scrim behind it.
|
||
TouchArea { }
|
||
|
||
kw-sheet := VerticalLayout {
|
||
padding: Theme.gap-lg;
|
||
spacing: Theme.gap;
|
||
|
||
Text {
|
||
text: root.selected-count == 1
|
||
? "Keywords for 1 photograph"
|
||
: "Keywords for " + root.selected-count + " photographs";
|
||
color: Theme.ink;
|
||
font-size: Theme.text-lg;
|
||
font-weight: 600;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// Typing a word applies it, whether or not it already exists.
|
||
// One field for both, because "is this keyword new?" is a
|
||
// question about the catalog and not about what the user meant,
|
||
// and Rust can answer it without being asked.
|
||
//
|
||
// The field clears itself on accept so the next word can be
|
||
// typed straight after — keywording a shoot is a run of them.
|
||
new-keyword := Field {
|
||
placeholder: "Type a keyword and press return";
|
||
// As the naming sheet below does, and for the same
|
||
// reason: the field is the only thing to do here. Without
|
||
// it the keys went to the grid behind the scrim, and the
|
||
// Return meant for the keyword opened a photograph.
|
||
init => { self.take-focus(); }
|
||
accepted(text) => {
|
||
root.assign-keyword(text);
|
||
self.text = "";
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
// A floor, so the list is not squeezed out of existence by
|
||
// the field and the button around it on a short window.
|
||
min-height: 120px;
|
||
viewport-height: root.keywords.length * (Theme.touch-target + 2px);
|
||
|
||
for word[i] in root.keywords: Rectangle {
|
||
y: i * (Theme.touch-target + 2px);
|
||
width: parent.width;
|
||
// A full touch target per row, for the same reason the
|
||
// filing sheet uses one: this is a place to hit once,
|
||
// with a thumb, holding a selection that took a minute
|
||
// to build (FR-UI-3).
|
||
height: Theme.touch-target;
|
||
background: kw-touch.pressed ? Theme.pressed
|
||
: (kw-touch.has-hover ? Theme.hover : transparent);
|
||
border-radius: Theme.radius-sm;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap-sm;
|
||
padding-right: Theme.gap-sm;
|
||
spacing: Theme.gap-sm;
|
||
|
||
// Tick, dash, or nothing — the three states of
|
||
// `coverage`, drawn as three different marks rather
|
||
// than as two. A half-applied keyword shown as
|
||
// applied is a lie about photographs the user
|
||
// cannot see from here.
|
||
Rectangle {
|
||
width: 16px;
|
||
y: (parent.height - self.height) / 2;
|
||
height: 16px;
|
||
border-radius: Theme.radius-sm;
|
||
border-width: 1px;
|
||
border-color: word.coverage == 0 ? Theme.rule : Theme.active;
|
||
background: word.coverage == 2 ? Theme.active : transparent;
|
||
|
||
// The dash for "some of them". A bar rather
|
||
// than a tick, because a tick at half strength
|
||
// reads as a rendering artefact.
|
||
if word.coverage == 1: Rectangle {
|
||
width: 8px;
|
||
height: 2px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
background: Theme.active;
|
||
}
|
||
if word.coverage == 2: Icon {
|
||
name: "check";
|
||
ink: Theme.surface;
|
||
size: 12px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: word.name;
|
||
color: Theme.ink;
|
||
font-size: Theme.text;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
// "3 of 12" only where it says something the mark
|
||
// does not. For a word the whole selection carries,
|
||
// or none of it, the mark has already said it and
|
||
// the number would be noise on every row.
|
||
Text {
|
||
text: word.coverage == 1
|
||
? word.selected-count + " of " + root.selected-count
|
||
: (word.image-count > 0 ? word.image-count + "" : "");
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// One target for both directions. A word the selection
|
||
// fully carries comes off; anything else goes on — so a
|
||
// partly-applied keyword is completed rather than
|
||
// removed, which is what a user tapping a dash means
|
||
// nine times in ten, and the tenth is one more tap
|
||
// away.
|
||
kw-touch := TouchArea {
|
||
clicked => {
|
||
if (word.coverage == 2) {
|
||
root.unassign-keyword(word.name);
|
||
} else {
|
||
root.assign-keyword(word.name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if root.keywords.length == 0: Text {
|
||
text: "No keywords yet. Type one above to make the first.";
|
||
color: Theme.ink-faint;
|
||
font-size: Theme.text-sm;
|
||
wrap: word-wrap;
|
||
width: parent.width;
|
||
}
|
||
}
|
||
|
||
Rectangle { height: 1px; background: Theme.rule; }
|
||
|
||
Button {
|
||
text: "Done";
|
||
clicked => { root.keywording = false; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- what is selected, and what to do with it ------------------------
|
||
//
|
||
// The count and these actions used to live only in the header row,
|
||
// which scrolls sideways: on a tablet "12 selected" and everything
|
||
// beside it sat past the right-hand edge, so a selection was something
|
||
// you could make and then not see. A selection you cannot see is one
|
||
// you act on by accident.
|
||
//
|
||
// **Every selection verb, in one place.** For a while this bar held the
|
||
// ones that *shape* a selection — clear it, extend it, select the lot —
|
||
// while the ones that *act* on it stayed in the header, forty-odd pixels
|
||
// from the top of a window whose bottom edge is where the count is. So
|
||
// "what can I do with these twelve?" was answered in two places at
|
||
// opposite ends of the screen, and the header half moved sideways every
|
||
// time the answer changed. Both halves are here now: shaping first, then
|
||
// acting, separated by a gap rather than by a journey.
|
||
//
|
||
// **Over the grid, not above it.** As a row in the vertical flow it
|
||
// appeared the instant the first photograph was selected, and every
|
||
// cell in the grid moved 40px — so the act of selecting shifted the
|
||
// thing being selected out from under the finger, and a second tap
|
||
// aimed at the neighbouring photograph landed on the row below it.
|
||
//
|
||
// A floating bar cannot do that: nothing above it is re-laid out, so
|
||
// selecting changes what is drawn and never where. The grid's viewport
|
||
// grows by the same 40px while it is there (see `selection-inset`), so
|
||
// the last row can still be scrolled clear of it.
|
||
//
|
||
// Shown for a running export whatever the selection has since become. The
|
||
// batch is what `Cancel export` refers to by then, and a run of three
|
||
// hundred that could only be stopped by not touching the selection would
|
||
// be a trap.
|
||
if root.selected-count > 0 || root.exporting: Rectangle {
|
||
y: parent.height - self.height;
|
||
height: 40px;
|
||
background: Theme.surface;
|
||
|
||
// Swallows presses that land on the bar rather than letting them
|
||
// through to the cell beneath. A bar floating over the grid is a bar a
|
||
// thumb can reach for and miss into.
|
||
//
|
||
// Before the Flickable below, so the flick that scrolls this row is
|
||
// hit-tested first and this only catches what misses it.
|
||
TouchArea { }
|
||
|
||
// Reads as a bar rather than as a strip of grid that happens to be
|
||
// grey: the cells behind it run right up to this edge.
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// Scrolls sideways, for the reason the header and the chip row both
|
||
// give at length: **a layout cannot be narrower than its children's
|
||
// minimums**, so a row of ten buttons handed 768 logical pixels does
|
||
// not shrink, it overruns — and the buttons past the edge are simply
|
||
// gone. That was survivable while this bar held four controls and is
|
||
// not now that it holds every verb.
|
||
//
|
||
// `min-width`, not `preferred-width`, exactly as the header row uses:
|
||
// the count beside the buttons elides, so the row genuinely has a
|
||
// smaller footprint than its natural one and a window with room for
|
||
// the shrunk row should get it shrunk rather than scrolled.
|
||
//
|
||
// The row scrolls sideways once it outgrows the window, which at a
|
||
// desktop width it does. Export is pinned beside it rather than inside
|
||
// it: last in a scrolling row put it past the right edge, and with a
|
||
// mouse nothing says the row scrolls — so batch export looked like a
|
||
// feature the library did not have. A layout, not the Rectangle, is
|
||
// the parent of the two, or both sit at the origin and overlap.
|
||
HorizontalLayout {
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
|
||
Flickable {
|
||
horizontal-stretch: 1;
|
||
viewport-height: self.height;
|
||
viewport-width: max(self.width, selection-row.min-width);
|
||
|
||
selection-row := HorizontalLayout {
|
||
width: parent.viewport-width;
|
||
height: parent.viewport-height;
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
// No `alignment: start`, which this row used to carry.
|
||
//
|
||
// Slint only distributes space to `horizontal-stretch` children
|
||
// under the default `stretch` alignment; under `start` every child
|
||
// gets its preferred width and stretch is silently ignored. With
|
||
// four controls that was invisible. With every selection verb on
|
||
// the bar it is the difference between a count that gives way and
|
||
// a row that can only scroll, so the count keeps its stretch and
|
||
// this keeps the default.
|
||
|
||
// While a range is armed the strip stops reporting and starts
|
||
// instructing. The count is still true, but it is not what the
|
||
// user needs to read: they have pressed something that changes
|
||
// what the *next* tap means, and a mode with no visible state
|
||
// is the double-tap gesture this replaces.
|
||
if root.ranging: Value {
|
||
text: "Tap the last photograph";
|
||
modified: true;
|
||
font-weight: 600;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
// Stretch is what actually lets a `Text` shrink under
|
||
// pressure in Slint, not `elide` alone — see the note on the
|
||
// header row's Flickable. Without it this readout keeps its
|
||
// full natural width and is what pushes the buttons beside
|
||
// it past the right-hand edge.
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
if root.ranging: Rectangle { width: Theme.gap; }
|
||
|
||
// Armed by mistake, or armed and thought better of. Without
|
||
// this the only way out is to tap a cell, which takes a run the
|
||
// user did not want and then has to be undone by hand.
|
||
if root.ranging: Button {
|
||
text: "Cancel";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.ranging = false; }
|
||
}
|
||
|
||
// State rather than a label: it is what the buttons beside it
|
||
// act on.
|
||
//
|
||
// A running export with nothing selected says so rather than
|
||
// reporting "0 photographs selected", which would be true and
|
||
// useless — the bar is on screen at that moment for the batch,
|
||
// not for the selection.
|
||
if !root.ranging: Value {
|
||
text: root.selected-count == 0
|
||
? "Exporting…"
|
||
: root.selected-count + (root.selected-count == 1
|
||
? " photograph selected" : " photographs selected");
|
||
modified: true;
|
||
font-weight: 600;
|
||
vertical-alignment: center;
|
||
overflow: elide;
|
||
// See the `ranging` readout above: stretch, not `elide`, is
|
||
// what lets this collapse instead of shoving the buttons off
|
||
// the end of the bar.
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
if !root.ranging: Rectangle { width: Theme.gap; }
|
||
|
||
// The way out that is not "undo every tap". Distinct from
|
||
// "Done", which leaves select mode entirely: clearing keeps the
|
||
// mode, so the next selection can start straight away.
|
||
// GESTURE: Drop the selection but keep selecting
|
||
// where: Library grid
|
||
// touch: Press Clear in the selection strip
|
||
// pointer: Press Clear in the selection strip
|
||
// why: Distinct from Done, which leaves the mode
|
||
// entirely. Clearing keeps it, so the next
|
||
// selection can start straight away.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Clear";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.clear-selection(); }
|
||
}
|
||
|
||
// Everything the grid is showing. Cheap to offer and tedious to
|
||
// do by hand: a scoped grid of two hundred frames is two hundred
|
||
// taps otherwise, and "all of them, except those three" is a far
|
||
// more common shape than the taps it took to say it.
|
||
// GESTURE: Select everything the grid is showing
|
||
// where: Library grid
|
||
// touch: While selecting, press "Select all"
|
||
// pointer: While selecting, press "Select all"
|
||
// keys: Ctrl+A
|
||
// why: A scoped grid of two hundred frames is two hundred
|
||
// taps otherwise, and "all of them, except those
|
||
// three" is a far more common shape than the taps it
|
||
// took to say it.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Select all";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.select-all(); }
|
||
}
|
||
|
||
// The visible half of `ranging` — see its declaration for why
|
||
// the double-tap it replaces was not good enough. Deliberately
|
||
// an unfinished sentence: the ellipsis is the promise that a
|
||
// second tap is coming.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Select to…";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.ranging = true; }
|
||
}
|
||
|
||
// --- and here the bar turns from shaping to acting -------------
|
||
//
|
||
// A gap rather than a rule: the two halves are the same subject
|
||
// and the eye only needs to be told they are different sentences.
|
||
if !root.ranging: Rectangle { width: Theme.gap-lg; }
|
||
|
||
// TRACES: FR-CAT-7 | FR-UI-4
|
||
// File the selection in a collection without dragging it there.
|
||
// The drag is the faster gesture with a pointer and impossible
|
||
// with one finger on a grid that scrolls, which is the whole
|
||
// reason this button exists.
|
||
//
|
||
// The only filing button on the bar. "New collection" used to sit
|
||
// beside it, which put the same decision — where do these go? — in
|
||
// front of the user twice, before they had seen the list that
|
||
// answers it. The sheet asks once and offers "New collection…"
|
||
// underneath its list, which is where you look after failing to
|
||
// find the one you wanted.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Add to collection";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
// Reset every time it opens: see `filing-moves`.
|
||
root.filing-moves = false;
|
||
root.filing = true;
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-CAT-5 | FR-CAT-6
|
||
// Keyword the selection. Beside the two filing buttons because
|
||
// they are the same thought — these photographs are *of*
|
||
// something, and they belong *with* something.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Keywords";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
// Ask for the vocabulary before showing the sheet, so it
|
||
// is answered against the selection as it stands now
|
||
// rather than as it stood when the grid last loaded.
|
||
root.keywords-opened();
|
||
root.keywording = true;
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-CAT-5 | FR-UI-4
|
||
// Label the selection. Opens the six choices in the bar itself —
|
||
// a mark and a name each — and closes on the one chosen.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Label";
|
||
active: root.labelling;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.labelling = !root.labelling; }
|
||
}
|
||
|
||
if !root.ranging && root.selected-count > 0 && root.labelling: LabelPicker {
|
||
chosen(code) => {
|
||
root.label-chosen(code);
|
||
root.labelling = false;
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-DEV-6
|
||
// Paste the copied edit onto the selection. One of the things a
|
||
// selection can have done to it, like filing it in a collection,
|
||
// so it is on the bar whenever there is a selection — disabled
|
||
// until something has been copied in develop. Hidden until then,
|
||
// it was a feature nobody could find (FR-UI-4).
|
||
//
|
||
// The count is in the label rather than in a confirmation: this
|
||
// writes to every selected image, and "Paste to 40" said before
|
||
// the click is worth more than a dialogue asking the same
|
||
// question after it.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Paste to " + root.selected-count;
|
||
enabled: root.settings-armed;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.paste-settings-to-selection(); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-6
|
||
// The saved settings, beside the copied ones.
|
||
//
|
||
// Enabled on the selection alone, unlike the paste before it:
|
||
// that needs a clipboard *this session*, where the preset list is
|
||
// whatever the photographer saved last month.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Presets";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.open-presets(); }
|
||
}
|
||
|
||
// TRACES: FR-MRG-1
|
||
// A panorama is a selection of at least two; one frame has
|
||
// nothing to merge with, so the button waits for a second.
|
||
if !root.ranging && root.selected-count > 1: Button {
|
||
text: "Merge to panorama";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.merge-selection(); }
|
||
}
|
||
|
||
// Removing from a collection is only meaningful while the grid is
|
||
// scoped to one. Offering it unscoped would invite the reading
|
||
// "remove from the library", which nothing here does.
|
||
if !root.ranging && root.selected-count > 0 && root.scope-label != "": Button {
|
||
text: "Remove from collection";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.remove-from-collection(); }
|
||
}
|
||
|
||
// TRACES: FR-CAT-7 | FR-UI-4
|
||
// Where this selection is filed, and the way out of any of it.
|
||
//
|
||
// Unscoped as well as scoped, unlike the button above: this is the
|
||
// one control that can name a collection the grid is not showing,
|
||
// and hiding it outside a scope would leave "which collections is
|
||
// this photograph in" answerable only by visiting each one.
|
||
// GESTURE: Take photographs out of a collection
|
||
// where: Library grid
|
||
// touch: Select them, then "Collections…" in the selection bar
|
||
// pointer: Select them, then "Collections…" in the selection bar
|
||
// why: The badge on a cell says a photograph is filed in
|
||
// three collections and never which. This is the sheet
|
||
// that names them, and the only way out of one the grid
|
||
// is not currently scoped to.
|
||
if !root.ranging && root.selected-count > 0: Button {
|
||
text: "Collections…";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.open-membership(); }
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-EXP-7 | NFR-ARCH-3
|
||
// Export the selection, and stop the batch that is running.
|
||
//
|
||
// One button doing both, because they are the same thought a
|
||
// moment apart and a separate cancel would have to appear from
|
||
// somewhere — shifting the row under the pointer at the exact
|
||
// moment the user is reaching for it.
|
||
//
|
||
// Pinned at the right end of the bar, outside the scrolling row
|
||
// (see above), because it is the last thing done to a selection
|
||
// and the one control here that outlives the selection: the bar
|
||
// stays up for a running batch, so this is always reachable while
|
||
// there is something to cancel.
|
||
//
|
||
// The count is in the label rather than behind a confirmation,
|
||
// exactly as the paste above puts it there: "Export 40" read
|
||
// before the click is worth more than a dialogue asking the same
|
||
// question after it.
|
||
if !root.ranging: Button {
|
||
text: root.exporting
|
||
? "Cancel export"
|
||
: (root.export-to-server
|
||
? "Export " + root.selected-count + " to the library"
|
||
: "Export " + root.selected-count);
|
||
active: root.exporting;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
if (root.exporting) {
|
||
root.cancel-export();
|
||
} else {
|
||
root.export-selection();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-UI-2 | FR-UI-4
|
||
// The gesture reference. Last of the sheets, and above them all, because it
|
||
// is the one a user opens *because* another one confused them.
|
||
if root.helping: GestureSheet {
|
||
width: 100%;
|
||
height: 100%;
|
||
rows: root.gestures;
|
||
close => { root.helping = false; }
|
||
}
|
||
|
||
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
|
||
//
|
||
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
|
||
// above. The short version is that on a tablet the sidebar is instantiated
|
||
// but not drawn, so the rename field could take the keyboard without ever
|
||
// being visible.
|
||
//
|
||
// The same card, scrim and dismissal as the two sheets above it, for the
|
||
// same reason they share one: a user who has filed a selection knows how
|
||
// this works.
|
||
if root.naming: Rectangle {
|
||
background: #000000CC;
|
||
|
||
// Swallows the taps that miss the card, and closes. First, so the
|
||
// card's own controls sit above it.
|
||
TouchArea {
|
||
clicked => { root.naming = false; }
|
||
}
|
||
|
||
Rectangle {
|
||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||
height: min(name-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||
x: (parent.width - self.width) / 2;
|
||
// A third of the way down, not centred. The field below takes the
|
||
// keyboard as the sheet appears, and on a tablet the keyboard is
|
||
// the bottom half of the window — a card centred in the window is a
|
||
// card centred behind it.
|
||
y: max(Theme.gap-lg, (parent.height - self.height) / 3);
|
||
background: Theme.surface;
|
||
border-radius: Theme.radius;
|
||
border-width: 1px;
|
||
border-color: Theme.rule;
|
||
|
||
// Stops a press on the card reaching the scrim behind it.
|
||
TouchArea { }
|
||
|
||
name-sheet := VerticalLayout {
|
||
padding: Theme.gap-lg;
|
||
spacing: Theme.gap;
|
||
|
||
Text {
|
||
text: root.selected-count == 1
|
||
? "New collection holding 1 photograph"
|
||
: "New collection holding " + root.selected-count
|
||
+ " photographs";
|
||
color: Theme.ink;
|
||
font-size: Theme.text-lg;
|
||
font-weight: 600;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
name-field := Field {
|
||
placeholder: "Name this collection";
|
||
// The card asks one question, so the field answers the
|
||
// keyboard for it. This is also what raises the on-screen
|
||
// keyboard on Android — over a field that is on screen,
|
||
// which is the whole difference from the old path.
|
||
init => { self.take-focus(); }
|
||
// Return commits, as it does in every other field here.
|
||
// Guarded rather than trusting `enabled` on the button
|
||
// beside it: this is a second way in and it must refuse an
|
||
// empty name on its own.
|
||
accepted(text) => {
|
||
if (text != "") {
|
||
root.collection-from-selection(text);
|
||
root.naming = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
alignment: end;
|
||
|
||
Button {
|
||
text: "Cancel";
|
||
clicked => { root.naming = false; }
|
||
}
|
||
|
||
Button {
|
||
text: "Create";
|
||
primary: true;
|
||
// A collection called "New collection" is the state
|
||
// this sheet exists to prevent, so Create waits for a
|
||
// name rather than inventing one.
|
||
enabled: name-field.text != "";
|
||
clicked => {
|
||
root.collection-from-selection(name-field.text);
|
||
root.naming = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|