Two breaks that only appeared on a full `cargo test --workspace`. `slint::android` exists only when compiling for Android, so darkroom-android failed to compile on the host even though it is a workspace member. The entry point is now gated on the target rather than on a feature. The timeline forwarded `scrub` where the Timeline component declares `scrub-to`, which the Slint compiler rejects. Assisted-by: LLM
1248 lines
58 KiB
Plaintext
1248 lines
58 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, Label, Value, Caption, EmptyState, FilterChip } from "widgets.slint";
|
||
|
||
// 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
|
||
//
|
||
// # 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;
|
||
/// Index of that bucket among `bars`, supplied by Rust. Slint has no array
|
||
/// search, and a component that quietly returned the wrong index would put
|
||
/// the marker in a plausible but false position.
|
||
in property <int> current-index: -1;
|
||
/// True once the user has taken control. Until then the marker rests at the
|
||
/// middle rather than pinning to either end, which would imply a selection
|
||
/// that has not been made.
|
||
in property <bool> anchored: false;
|
||
|
||
/// Scrub to a fraction along the visible span. Rust turns it into an
|
||
/// instant, interpolating within a bucket rather than snapping to its edge.
|
||
callback scrub-to(float);
|
||
callback pan(float);
|
||
callback zoom(int);
|
||
/// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches
|
||
/// them together. Continuous, unlike the wheel's discrete steps.
|
||
callback pinch(float);
|
||
|
||
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);
|
||
|
||
/// 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);
|
||
}
|
||
|
||
// 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-index >= 0
|
||
? root.track-top + root.current-index * root.slot
|
||
: root.track-top + root.track-height / 2;
|
||
}
|
||
|
||
// 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 (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;
|
||
if (!self.panning && root.bars.length > 0) {
|
||
root.scrub-to(root.fraction-at(self.mouse-y));
|
||
}
|
||
}
|
||
if (e.kind == PointerEventKind.up) { self.panning = false; }
|
||
}
|
||
|
||
scroll-event(e) => {
|
||
// Wheel zooms rather than scrolls: the sidebar is an axis, not a
|
||
// list, and a scroll gesture over it means "show me more or less
|
||
// time" rather than "move down".
|
||
root.zoom(e.delta-y > 0 ? 1 : -1);
|
||
return accept;
|
||
}
|
||
|
||
changed has-hover => {
|
||
if (!self.has-hover) { root.hovered = -1; }
|
||
}
|
||
}
|
||
}
|
||
|
||
export struct LibraryCell {
|
||
name: string,
|
||
// Non-empty on the first cell of a new month, e.g. "August 2026". The grid
|
||
// is ordered by capture time, so these are the only place the date is
|
||
// legible without consulting the sidebar — a wall of thumbnails otherwise
|
||
// gives no sense of when you are.
|
||
period-heading: string,
|
||
// Empty until a fetch lands. `has-thumb` disambiguates, because Slint
|
||
// cannot test an image against null.
|
||
thumbnail: image,
|
||
has-thumb: bool,
|
||
// A fetch that completed with no usable preview. Distinct from pending.
|
||
unavailable: bool,
|
||
// Part of the current selection. Selection is what a drag carries, so this
|
||
// has to be per-cell state rather than a single "current" index.
|
||
selected: bool,
|
||
// 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,
|
||
}
|
||
|
||
// A horizontal progress bar with two modes.
|
||
//
|
||
// Determinate where a real denominator exists (thumbnails: we know how many
|
||
// cells we asked for). Indeterminate where one does not — a directory walk
|
||
// discovers its own extent, so any percentage would be invented, and inventing
|
||
// one is worse than admitting the work is unbounded.
|
||
component ProgressBar inherits Rectangle {
|
||
in property <float> fraction: 0;
|
||
in property <bool> indeterminate: false;
|
||
|
||
height: 3px;
|
||
background: Theme.rule;
|
||
|
||
// Determinate: a bar proportional to real progress.
|
||
Rectangle {
|
||
x: 0;
|
||
width: parent.width * clamp(root.fraction, 0, 1);
|
||
height: parent.height;
|
||
background: Theme.active-dim;
|
||
visible: !root.indeterminate;
|
||
}
|
||
|
||
// Indeterminate: a sweep that says "working" without claiming a position.
|
||
Rectangle {
|
||
width: parent.width * 25%;
|
||
height: parent.height;
|
||
background: Theme.active-dim;
|
||
visible: root.indeterminate;
|
||
|
||
x: root.indeterminate ? -self.width : 0;
|
||
animate x {
|
||
duration: 1200ms;
|
||
iteration-count: -1;
|
||
easing: ease-in-out;
|
||
}
|
||
states [
|
||
running when root.indeterminate: { x: parent.width; }
|
||
]
|
||
}
|
||
}
|
||
|
||
// 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 glyph 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
|
||
// glyph, 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 glyphs.
|
||
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;
|
||
|
||
Text {
|
||
text: "🗑";
|
||
// 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 glyph carries it, not the
|
||
// colour).
|
||
color: Theme.warn-ink;
|
||
font-size: 13px;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
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;
|
||
|
||
Text {
|
||
// Solid versus outline: the shape says it, not the colour.
|
||
text: root.rating >= n ? "★" : "☆";
|
||
color: root.rating >= n ? Theme.active : Theme.ink-dim;
|
||
// Large enough to hit the difference between ★ and ☆ at arm's
|
||
// length on a tablet; the glyphs differ in fill, which needs
|
||
// more pixels to read than a difference in shape would.
|
||
font-size: 15px;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
// Grown past the drawn star to meet FR-UI-3's 44pt minimum, and
|
||
// centred on it. The overhang overlaps its neighbours, so the
|
||
// *later* star wins the shared band — which is why this is
|
||
// acceptable here: adjacent targets belong to the same control and
|
||
// a near-miss sets a rating one star out, not something unrelated.
|
||
//
|
||
// Vertical overhang spills outside the strip onto the thumbnail,
|
||
// which carries no hit area of its own — the cell's TouchArea is
|
||
// below this in z-order.
|
||
TouchArea {
|
||
enabled: root.interactive;
|
||
width: max(parent.width, Theme.touch-target);
|
||
height: max(parent.height, Theme.touch-target);
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
mouse-cursor: root.interactive ? MouseCursor.pointer
|
||
: MouseCursor.default;
|
||
// Clicking the star already set clears the rating — the
|
||
// gesture every photo tool uses for "undo that", and without
|
||
// it the only way back to unrated is the keyboard.
|
||
clicked => { root.rate(root.rating == n ? 0 : n); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The pick/reject mark.
|
||
//
|
||
// A glyph rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
||
// ✓ and ✗ 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 glyph 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;
|
||
|
||
Text {
|
||
text: root.flag == 2 ? "✗" : "✓";
|
||
// 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.
|
||
color: root.flag == 2 ? Theme.warn-ink : Theme.active;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
export component LibraryGrid inherits Rectangle {
|
||
in property <[LibraryCell]> cells;
|
||
in property <int> total: 0;
|
||
in property <bool> scanning: false;
|
||
in property <string> scan-status: "";
|
||
in property <string> scan-error: "";
|
||
/// Which folder is being shown. Visible at all times: two similarly-named
|
||
/// folders are easy to confuse, and a scan of the wrong one looks
|
||
/// identical to a broken scan.
|
||
in property <string> root-label: "";
|
||
|
||
// --- capture-time scrubber ---
|
||
in property <[TimelineBar]> timeline;
|
||
in property <string> timeline-label: "";
|
||
/// Dates spanned by the cells currently shown.
|
||
in property <string> window-label: "";
|
||
in property <int> offset: 0;
|
||
/// Where the view should be, as an image ordinal. A scrub sets this; the
|
||
/// grid follows it.
|
||
///
|
||
/// Bumped by `scroll-token` rather than watched directly: scrubbing twice
|
||
/// to the same date must still move the view, and an unchanged property
|
||
/// fires no `changed` handler.
|
||
in property <int> scroll-to: 0;
|
||
in property <int> scroll-token: 0;
|
||
|
||
/// Which bucket the grid currently sits in, and where that is among the
|
||
/// bars. Slint cannot search an array, so Rust supplies both.
|
||
in property <int> current-bucket: 0;
|
||
in property <int> current-bucket-index: -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;
|
||
|
||
callback scrub(int);
|
||
callback timeline-pan(float);
|
||
callback timeline-zoom(int);
|
||
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);
|
||
/// The viewport can now hold a different number of cells — a resize, or a
|
||
/// column-count change. Rust resizes the loaded window to match.
|
||
callback capacity-changed(int);
|
||
|
||
// Thumbnail progress. Unlike the scan, this has a real denominator — the
|
||
// number of cells we asked for — so the bar can be honest about position.
|
||
in property <int> thumbs-done: 0;
|
||
in property <int> thumbs-total: 0;
|
||
|
||
// 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;
|
||
|
||
property <bool> thumbs-running: root.thumbs-total > 0
|
||
&& root.thumbs-done < root.thumbs-total;
|
||
|
||
callback cell-clicked(int);
|
||
/// A star was clicked on a cell: row, and the rating 0..5.
|
||
callback cell-rated(int, int);
|
||
/// Whether the grid is currently listing the trash rather than the
|
||
/// library. Suppresses the per-cell trash target, which would be inert
|
||
/// there — `plan_trash` skips an already-trashed image — and offering a
|
||
/// control that does nothing is worse than offering none.
|
||
in property <bool> viewing-trash: false;
|
||
/// The trash target was clicked on a cell. Acts on that one photograph,
|
||
/// like the stars beside it — the pointer names it unambiguously, and a
|
||
/// click that quietly trashed a whole selection would be a trap. `Delete`
|
||
/// is the bulk gesture.
|
||
callback cell-trashed(int);
|
||
/// Move the selection to the trash — the `Delete` key.
|
||
callback trash-selection();
|
||
/// A rating or flag key was pressed while the grid had focus. Applies to
|
||
/// the whole selection, which is what makes rating forty frames one
|
||
/// gesture.
|
||
///
|
||
/// Rating and flag travel on one callback because they are one keystroke
|
||
/// as far as the user is concerned; Rust decodes which axis was meant.
|
||
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
|
||
/// star, so neither axis is disturbed by a press on the other.
|
||
callback judged(int, int);
|
||
/// `F2` — rename the collection the grid is scoped to. The key lives with
|
||
/// the grid because that is what holds focus in library mode, but the
|
||
/// rename itself happens in the sidebar.
|
||
callback rename-scope();
|
||
callback rescan();
|
||
/// Back to the launch screen, to change library or account.
|
||
callback change-library();
|
||
|
||
// --- 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);
|
||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||
/// drag starts, so it always reflects the selection as it is at that moment.
|
||
pure callback drag-payload() -> data-transfer;
|
||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||
/// of them where several are being carried. Composited in Rust, because
|
||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||
in property <image> drag-image;
|
||
/// A drag began on this cell. Lets Rust promote an unselected cell to the
|
||
/// selection before the payload is read.
|
||
callback drag-started(int);
|
||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||
callback drag-finished();
|
||
|
||
// --- rating filter (FR-CAT-6, FR-CULL-4) ---
|
||
//
|
||
// The filter narrows what the grid *queries*, not what it draws: on a
|
||
// remote library, drawing then hiding would still have fetched every
|
||
// thumbnail, which is the cost FR-NC-3 exists to avoid.
|
||
/// Minimum stars to show. 0 shows everything.
|
||
in property <int> filter-min-rating: 0;
|
||
/// Show only images nothing has judged yet — FR-CULL-4's "filter to
|
||
/// unjudged", which is what lets a culling session resume.
|
||
in property <bool> filter-unjudged: false;
|
||
/// 0 no flag filter, 1 picks only, 2 rejects only.
|
||
in property <int> filter-flag: 0;
|
||
/// How many images sit at each star count, index 0 being unrated. Shown
|
||
/// on the filter buttons so the user can see there is something behind a
|
||
/// filter before narrowing to it — a filter that silently empties the
|
||
/// grid reads as broken.
|
||
in property <[int]> rating-counts;
|
||
|
||
callback filter-min-rating-changed(int);
|
||
callback filter-unjudged-toggled(bool);
|
||
callback filter-flag-changed(int);
|
||
|
||
/// How many images are selected, for the header's count.
|
||
in property <int> selected-count: 0;
|
||
/// 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();
|
||
|
||
// Cell geometry. Columns are derived from the available width so the grid
|
||
// reflows with the window rather than fixing a count (FR-UI-1).
|
||
property <length> cell-size: 180px;
|
||
property <int> columns: max(1, floor((self.width - Theme.gap) / (cell-size + Theme.gap)));
|
||
// Reported out so Rust can place month headings: a heading belongs on a
|
||
// cell that begins a row, and only the grid knows how wide a row is.
|
||
changed columns => { root.columns-changed(root.columns); }
|
||
property <int> row-count: ceil(root.cells.length / max(1, columns));
|
||
|
||
// How many cells the viewport holds, plus a screenful either side so
|
||
// scrolling has loaded rows to move into rather than blank ones.
|
||
//
|
||
// Derived rather than a constant: a fixed window is simultaneously too
|
||
// small on a maximised 4K window — where three screenfuls fit inside it —
|
||
// and wasteful on a narrow one.
|
||
property <int> visible-rows: max(1, ceil(self.height / (cell-size + Theme.gap)));
|
||
property <int> capacity: root.columns * (root.visible-rows * 3);
|
||
changed capacity => { root.capacity-changed(root.capacity); }
|
||
/// 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;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
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;
|
||
}
|
||
|
||
Caption { text: root.total > 0 ? root.total + " images" : ""; }
|
||
|
||
// The selection count, and how to act on it. Both appear only
|
||
// when something is selected — an empty selection has nothing
|
||
// to say and the buttons would be permanently greyed chrome.
|
||
// A live selection is state, not a label — it is the thing the
|
||
// buttons beside it act on — so it keeps `active` rather than
|
||
// dropping to ink with the counts around it.
|
||
Value {
|
||
text: root.selected-count > 0
|
||
? root.selected-count + " selected" : "";
|
||
modified: true;
|
||
compact: true;
|
||
font-weight: 600;
|
||
}
|
||
|
||
Label {
|
||
// The scan's own status while it runs; once it is done,
|
||
// thumbnail progress takes the line over — that is the
|
||
// work the user is actually waiting on by then.
|
||
// No per-window preview count: it counted an arbitrary
|
||
// batch, so "48 / 120" described the window's size rather
|
||
// than anything the user cares about. Whole-library
|
||
// indexing is the number worth showing, and the bar below
|
||
// already says that the window itself is still filling.
|
||
text: root.scanning ? root.scan-status
|
||
: (root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.scan-status);
|
||
}
|
||
|
||
// Where in the library the visible window sits. A scrubbable
|
||
// grid is disorienting without it.
|
||
Caption {
|
||
text: root.sweeping
|
||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||
: root.window-label;
|
||
horizontal-alignment: right;
|
||
horizontal-stretch: 1;
|
||
overflow: elide;
|
||
}
|
||
|
||
// 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.
|
||
Button {
|
||
text: "Remove from collection";
|
||
y: (parent.height - self.height) / 2;
|
||
visible: root.scope-label != "" && root.selected-count > 0;
|
||
clicked => { root.remove-from-collection(); }
|
||
}
|
||
|
||
// The header is taller than a control, so these are centred
|
||
// in it rather than stretched to fill it.
|
||
Button {
|
||
text: "Change library";
|
||
y: (parent.height - self.height) / 2;
|
||
visible: !root.scanning;
|
||
clicked => { root.change-library(); }
|
||
}
|
||
|
||
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: (parent.height - self.height) / 2;
|
||
clicked => { root.sync-now(); }
|
||
}
|
||
|
||
Button {
|
||
text: "Rescan";
|
||
y: (parent.height - self.height) / 2;
|
||
visible: !root.scanning;
|
||
clicked => { root.rescan(); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- rating filter ------------------------------------------------
|
||
//
|
||
// Hidden while there is nothing to filter: an empty library offering
|
||
// six rating buttons is chrome describing data that does not exist.
|
||
if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged
|
||
|| root.filter-flag > 0: Rectangle {
|
||
height: 34px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: 4px;
|
||
alignment: start;
|
||
|
||
Caption {
|
||
text: "Show";
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
// Minimum-stars buttons. "All" first, then 1..5 — the same
|
||
// left-to-right increasing order as the star strip itself, so
|
||
// the two read as the same scale.
|
||
FilterChip {
|
||
label: "All";
|
||
active: root.filter-min-rating == 0 && !root.filter-unjudged
|
||
&& root.filter-flag == 0;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-min-rating-changed(0);
|
||
root.filter-unjudged-toggled(false);
|
||
root.filter-flag-changed(0);
|
||
}
|
||
}
|
||
|
||
// Unrated, which is where a freshly scanned library lives in
|
||
// its entirety — and what a resumed cull filters to.
|
||
FilterChip {
|
||
label: "Unrated";
|
||
count: root.rating-counts.length > 0 ? root.rating-counts[0] : -1;
|
||
active: root.filter-unjudged;
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => {
|
||
root.filter-unjudged-toggled(!root.filter-unjudged);
|
||
}
|
||
}
|
||
|
||
for n[i] in [1, 2, 3, 4, 5]: FilterChip {
|
||
label: "★" + n + "+";
|
||
count: root.rating-counts.length > n ? root.rating-counts[n] : -1;
|
||
active: root.filter-min-rating == n;
|
||
y: (parent.height - self.height) / 2;
|
||
// Pressing the active one clears it, so the filter is its
|
||
// own undo and "All" is not the only way back.
|
||
clicked => {
|
||
root.filter-min-rating-changed(
|
||
root.filter-min-rating == n ? 0 : n);
|
||
}
|
||
}
|
||
|
||
Rectangle { width: Theme.gap; }
|
||
|
||
FilterChip {
|
||
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 {
|
||
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 { 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)
|
||
? "filtered" : "";
|
||
emphasised: true;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// --- progress -----------------------------------------------------
|
||
//
|
||
// Indeterminate during the scan: a directory walk cannot know its own
|
||
// extent, so a percentage would be fiction. Determinate for
|
||
// thumbnails, where the denominator is the cells we requested.
|
||
if root.scanning || root.thumbs-running || root.sweeping: ProgressBar {
|
||
// Only the sweep has a denominator worth showing. A scan cannot
|
||
// know its extent, and the window's own fetches are sized by the
|
||
// viewport rather than by anything meaningful to the user.
|
||
indeterminate: root.scanning || (!root.sweeping && root.thumbs-running);
|
||
fraction: root.sweep-total > 0
|
||
? root.sweep-done / root.sweep-total
|
||
: 0;
|
||
}
|
||
|
||
// --- error --------------------------------------------------------
|
||
if root.scan-error != "": 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 `capacity`, 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-index: root.current-bucket-index;
|
||
anchored: root.timeline-anchored;
|
||
|
||
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); }
|
||
}
|
||
|
||
VerticalLayout {
|
||
horizontal-stretch: 1;
|
||
|
||
// --- empty state --------------------------------------------------
|
||
//
|
||
// "Still scanning" and "scanned, found nothing" are different answers.
|
||
// Conflating them is how a working scan looks broken.
|
||
if root.total == 0: EmptyState {
|
||
headline: root.scanning ? "Scanning…" : "No images found";
|
||
detail: root.scanning ? root.scan-status
|
||
: "Check the library folder and which formats are ticked.";
|
||
}
|
||
|
||
// --- keyboard judgement (FR-CULL-4) -------------------------------
|
||
//
|
||
// `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag.
|
||
// These are the keys every culling tool uses, and muscle memory
|
||
// built elsewhere is worth more here than any improvement.
|
||
//
|
||
// Zero-height rather than wrapping the grid: a FocusScope in this
|
||
// layout would claim a slot and push the grid up, and one *around*
|
||
// the Flickable competes with it for the arrow keys. This holds
|
||
// focus and forwards nothing else.
|
||
//
|
||
// Applies to the **selection**, not to a cell under the pointer —
|
||
// that is what makes rating forty frames a single keystroke, and it
|
||
// matches what the header's count says is selected.
|
||
judge-keys := FocusScope {
|
||
height: 0px;
|
||
// The grid is the primary surface of this screen, so it takes
|
||
// focus on show rather than waiting for a click. Without this
|
||
// the first keystroke of a culling session is swallowed.
|
||
init => { self.focus(); }
|
||
|
||
key-pressed(event) => {
|
||
if (event.text == "0") { root.judged(0, -1); return accept; }
|
||
if (event.text == "1") { root.judged(1, -1); return accept; }
|
||
if (event.text == "2") { root.judged(2, -1); return accept; }
|
||
if (event.text == "3") { root.judged(3, -1); return accept; }
|
||
if (event.text == "4") { root.judged(4, -1); return accept; }
|
||
if (event.text == "5") { root.judged(5, -1); return accept; }
|
||
// Case-insensitive: caps lock during a long cull must not
|
||
// silently stop the keys working.
|
||
if (event.text == "p" || event.text == "P") {
|
||
root.judged(-1, 1);
|
||
return accept;
|
||
}
|
||
if (event.text == "x" || event.text == "X") {
|
||
root.judged(-1, 2);
|
||
return accept;
|
||
}
|
||
if (event.text == "u" || event.text == "U") {
|
||
root.judged(-1, 0);
|
||
return accept;
|
||
}
|
||
// Delete moves the selection to the trash folder on the
|
||
// server. Unlike every other key here it is not metadata —
|
||
// it relocates files — but it is also the key every file
|
||
// manager binds to exactly this, and the operation is
|
||
// reversible from the trash view.
|
||
if (event.text == Key.Delete || event.text == Key.Backspace) {
|
||
root.trash-selection();
|
||
return accept;
|
||
}
|
||
// `F2` renames the collection the grid is scoped to — the
|
||
// rename key everywhere else, and the reason it is bound
|
||
// here is that this scope is what holds focus in library
|
||
// mode. Rust ignores it when nothing is scoped.
|
||
if (event.text == Key.F2) {
|
||
root.rename-scope();
|
||
return accept;
|
||
}
|
||
return reject;
|
||
}
|
||
}
|
||
|
||
// --- the grid -----------------------------------------------------
|
||
//
|
||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
||
// properly, so dragging a cell drags the cell and dragging the
|
||
// background still flicks the grid. (This is the part a hand-rolled
|
||
// TouchArea gesture could not do — see the drag comments above.)
|
||
if root.total > 0: grid-scroll := Flickable {
|
||
// 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.
|
||
property <int> token: root.scroll-token;
|
||
changed token => {
|
||
self.viewport-y = -min(
|
||
max(0px, self.viewport-height - self.height),
|
||
floor(root.scroll-to / max(1, root.columns))
|
||
* (root.cell-size + Theme.gap));
|
||
}
|
||
|
||
// Sized to the **whole library**, not the loaded window. The
|
||
// scrollbar has to represent 23,971 images or there is no way to
|
||
// reach image 20,000 — dragging it must be a real address, and the
|
||
// window is swapped underneath to match.
|
||
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap;
|
||
|
||
// Report the first fully-scrolled-past row so Rust can move the
|
||
// window. Derived rather than eventful: Slint has no scroll
|
||
// callback, and a `changed` handler on a derived integer fires only
|
||
// when the row actually changes rather than on every pixel.
|
||
property <int> first-visible-row: max(0,
|
||
floor((-self.viewport-y - Theme.gap) / (root.cell-size + Theme.gap)));
|
||
changed first-visible-row => {
|
||
root.scrolled(self.first-visible-row * root.columns);
|
||
}
|
||
|
||
// Month headings, drawn over the grid at the row where each
|
||
// period begins. A separate pass rather than part of the cell,
|
||
// because the heading spans the full width and a cell does not.
|
||
for cell[i] in root.cells: Text {
|
||
x: Theme.gap;
|
||
// Sits in the gap above its row, so it labels the row
|
||
// rather than displacing it.
|
||
y: Theme.gap
|
||
+ floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap)
|
||
- 15px;
|
||
width: parent.width - 2 * Theme.gap;
|
||
text: cell.period-heading;
|
||
color: Theme.ink-dim;
|
||
font-size: Theme.text-sm;
|
||
font-weight: 700;
|
||
visible: cell.period-heading != "";
|
||
}
|
||
|
||
for cell[i] in root.cells: DragArea {
|
||
// Cells are positioned at their **absolute** place in the
|
||
// library, not their index in the loaded window: the window
|
||
// starts at `offset`, so a cell drawn at window-index 0 belongs
|
||
// wherever `offset` sits in the full grid.
|
||
x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap);
|
||
y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap);
|
||
width: root.cell-size;
|
||
height: root.cell-size;
|
||
|
||
// Copy, not move: dropping into a collection files the
|
||
// photograph there without taking it out of anywhere else. That
|
||
// is what a join table means, and it is why the modifier-free
|
||
// gesture must not be `move`.
|
||
allow-copy: true;
|
||
data: root.drag-payload();
|
||
// What travels under the cursor is the photograph itself — and
|
||
// where several are being dragged, a stack of them. Composited
|
||
// in Rust (`drag_image`), because Slint takes a single bitmap
|
||
// here and cannot render a pile of thumbnails into one.
|
||
//
|
||
// Read on `dragging` rather than bound continuously: the
|
||
// composite costs a copy per thumbnail, and the grid must not
|
||
// pay it per cell per frame.
|
||
drag-image: root.drag-image;
|
||
|
||
changed dragging => {
|
||
if (self.dragging) {
|
||
root.drag-started(i);
|
||
}
|
||
}
|
||
drag-finished(action) => { root.drag-finished(); }
|
||
|
||
Rectangle {
|
||
// Lifted cells shrink toward their own centre, as though pulled
|
||
// off the page. Inset rather than scaled: Slint has no transform
|
||
// on a plain Rectangle, and insetting keeps the cell's slot in
|
||
// the grid so nothing reflows mid-drag.
|
||
x: cell.lifted ? 10px : 0px;
|
||
y: cell.lifted ? 10px : 0px;
|
||
width: parent.width - 2 * self.x;
|
||
height: parent.height - 2 * self.y;
|
||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||
|
||
background: cell.selected ? Theme.selected : Theme.surface;
|
||
border-radius: Theme.radius;
|
||
// Selection outranks hover: a selected cell must stay legible
|
||
// once the pointer has moved on to the collection it is being
|
||
// dragged toward.
|
||
border-width: cell.selected ? 2px : (cell-touch.has-hover ? 1px : 0px);
|
||
border-color: Theme.selected-ring;
|
||
clip: true;
|
||
|
||
VerticalLayout {
|
||
padding: 6px;
|
||
spacing: 4px;
|
||
|
||
Rectangle {
|
||
vertical-stretch: 1;
|
||
background: Theme.ground;
|
||
|
||
Image {
|
||
width: 100%;
|
||
height: 100%;
|
||
source: cell.thumbnail;
|
||
image-fit: contain;
|
||
visible: cell.has-thumb;
|
||
// Faded while lifted, so the grid reads as the place
|
||
// the photograph came *from* and the cursor as where
|
||
// it is now. `colorize` would flatten it to one
|
||
// tint, which loses the picture; dropping opacity
|
||
// toward the ground keeps it recognisable as a ghost
|
||
// of itself.
|
||
// A rejected frame is held back rather than
|
||
// hidden: the cull is reversible, and a photo
|
||
// that vanished on one keypress would make the
|
||
// gesture frightening to use. Dimming is the
|
||
// cue that reads at grid scale — the ✗ glyph
|
||
// confirms it up close.
|
||
opacity: cell.lifted ? 0.25
|
||
: (cell.flag == 2 ? 0.4 : 1.0);
|
||
animate opacity { duration: 120ms; }
|
||
}
|
||
|
||
if !cell.has-thumb: Caption {
|
||
text: cell.unavailable ? "no preview" : "…";
|
||
horizontal-alignment: center;
|
||
}
|
||
|
||
// "Already filed, in this many collections." Without it
|
||
// there is no way to tell a filed photograph from an
|
||
// unfiled one, and the user re-files what is already
|
||
// in place.
|
||
if cell.collection-count > 0: Rectangle {
|
||
x: parent.width - self.width - 4px;
|
||
y: 4px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
background: Theme.selected;
|
||
opacity: 0.9;
|
||
|
||
Text {
|
||
text: cell.collection-count;
|
||
color: Theme.ink;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
width: 100%;
|
||
height: 100%;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
// Pick or reject, top left — the opposite corner
|
||
// from the collection badge so the two never
|
||
// collide on a cell that carries both.
|
||
FlagMark {
|
||
x: 4px;
|
||
y: 4px;
|
||
flag: cell.flag;
|
||
}
|
||
|
||
}
|
||
|
||
Label {
|
||
text: cell.name;
|
||
emphasised: cell.selected;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
// Selection only. The drag is the enclosing `DragArea`'s
|
||
// business, and Slint keeps a click distinct from a drag for
|
||
// us — which is exactly the arbitration the hand-rolled version
|
||
// had to fake with a travel threshold.
|
||
cell-touch := TouchArea {
|
||
mouse-cursor: pointer;
|
||
|
||
// Selected on *press*, not on release: the drag that may
|
||
// follow reads the selection to build its payload, and by
|
||
// release the pointer is over the sidebar.
|
||
pointer-event(ev) => {
|
||
if (ev.kind == PointerEventKind.down) {
|
||
root.cell-pressed(
|
||
i,
|
||
ev.modifiers.control,
|
||
ev.modifiers.shift,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 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 on `clicked`, so the press above
|
||
// records it and Rust decides — `cell-clicked` is only
|
||
// honoured when the press was unmodified.
|
||
clicked => { root.cell-clicked(i); }
|
||
}
|
||
|
||
// --- the rating strip, ABOVE the cell's own hit area ---
|
||
//
|
||
// **Declared after `cell-touch` on purpose.** Slint hit-tests
|
||
// later siblings first, so a strip nested inside the layout
|
||
// above was underneath the cell-wide TouchArea: the click set
|
||
// a rating *and* fell through to `cell-clicked`, which threw
|
||
// the user into develop on every star press. Z-order is the
|
||
// whole fix — there is no "handled" flag to set, and adding a
|
||
// travel threshold or a timer would be faking arbitration
|
||
// Slint already does correctly once the order is right.
|
||
//
|
||
// 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. On touch there is no hover, so the
|
||
// strip is always present where a rating exists — and a
|
||
// long-press is not needed to discover it.
|
||
show-empty: cell-touch.has-hover;
|
||
can-trash: !root.viewing-trash;
|
||
rate(n) => { root.cell-rated(i, n); }
|
||
trash() => { root.cell-trashed(i); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|