Files
DarkRoom/ui/dr-ui/ui/library.slint
T
dtourolleandClaude Opus 5 f7e8cc99b1 Call them marks, not glyphs, now that they are drawn
The last comment left over from the icon change in 65e6a96, which moved
the star strip off Text and onto drawn paths. "Glyph" now names something
this file no longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:14:58 +02:00

1542 lines
73 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The library grid: what a scanned library looks like.
//
// Cells come from Rust as a windowed model, never the whole catalog — a 17k
// image library must not become 17k live elements (FR-CAT-4).
//
// A cell with no thumbnail yet shows why rather than an empty box. On a remote
// library a thumbnail is a network round trip, so "nothing there", "not
// fetched yet", and "no preview in this file" are three different states and
// must not look identical (FR-NC-6c).
import { Theme } from "theme.slint";
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } 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;
/// Where the grid sits along the visible span, 0..1, supplied by Rust.
///
/// **A fraction, not a bucket index.** The marker has to be placed by the
/// same quantity a click produces: `fraction-at` maps a y to a fraction of
/// the track and Rust interpolates an instant from it, so positioning the
/// marker from a bar index instead put it wherever that bucket's *slot*
/// happened to fall — never under the pointer. Negative means "not
/// anchored".
in property <float> current-fraction: -1;
/// True once the user has taken control. Until then the marker rests at the
/// middle rather than pinning to either end, which would imply a selection
/// that has not been made.
in property <bool> anchored: false;
/// Scrub to a fraction along the visible span. Rust turns it into an
/// instant, interpolating within a bucket rather than snapping to its edge.
callback scrub-to(float);
callback pan(float);
callback zoom(int);
/// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches
/// them together. Continuous, unlike the wheel's discrete steps.
callback pinch(float);
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-fraction >= 0
? root.track-top + clamp(root.current-fraction, 0.0, 1.0) * root.track-height
: 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 row of five stars, readable at a glance and clickable to set a rating.
//
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
// status by hue alone, and the palette is achromatic anyway — so a set star is
// a solid star at `active` and an unset one is an outline at `ink-faint`. The
// two differ in both shape and luminance, which survives greyscale and low
// vision alike.
//
// **Unrated draws nothing until hovered.** A grid of 120 cells each showing
// five empty stars is a wall of chrome competing with the photographs; a
// freshly scanned library would look like a spreadsheet. The strip appears on
// hover, so an unjudged cell is quiet and a judged one is legible from across
// the room.
export component StarStrip inherits Rectangle {
in property <int> rating: 0;
/// Show the empty stars even at zero — used while the pointer is over the
/// cell, so there is something to aim at.
in property <bool> show-empty: false;
/// Whether clicking sets a rating. False in a read-only context.
in property <bool> interactive: true;
/// Whether to offer the trash target at all. Hidden in the trash view,
/// where these photographs are already there — `plan_trash` would skip
/// them anyway, so the control would be inert, and an inert control that
/// looks live is worse than no control.
in property <bool> can-trash: true;
/// A star was clicked: the rating it stands for, 1..5.
callback rate(int);
/// The trash target was clicked. Named separately from [`rate`] rather
/// than being rating `-1`: this moves a file on the server, and a callback
/// that could mean either "set a rating" or "delete a photograph"
/// depending on the sign is one typo away from the wrong one.
callback trash();
// One star's drawn box. The *ink* stays small — five 44px stars would be
// 220px wide and swamp a 180px cell — while the hit area below grows to
// `Theme.touch-target`, which is the same split `Button` and `FormatCheck`
// make for the same FR-UI-3 reason.
property <length> star: 22px;
// Separation between the trash target and ★1.
//
// **This gap is load-bearing.** The star targets are 44px over a 22px
// cell, so they deliberately overlap and a near-miss lands one star out —
// harmless, same control, corrected by clicking again. That reasoning does
// not survive a neighbour that *moves a file*, so trash is held off the
// scale by a gap wider than the overhang it would otherwise share with
// ★1. A slip between them hits nothing at all, which is the correct
// outcome for an ambiguous press next to a destructive target.
property <length> trash-gap: 14px;
height: root.star;
// Sized to its content so the cell's layout does not reserve space for a
// strip that may be invisible.
width: 5 * root.star + (root.can-trash ? root.star + root.trash-gap : 0px);
// A ground behind the stars: they sit over a photograph that may be white
// at that point, and an outline star on a bright sky is invisible. Also
// makes the strip read as one control rather than five loose marks.
background: root.visible ? Theme.surface.with-alpha(0.75) : transparent;
border-radius: Theme.radius;
visible: root.rating > 0 || root.show-empty;
HorizontalLayout {
// Trash sits at the *left*, before the scale rather than beyond its
// top: reading left to right it is "remove this" and then a rising
// scale, which keeps ★5 at the end where a rating scale is expected
// to peak. Putting it past ★5 would make the strip read as a
// six-point scale whose last stop deletes.
Rectangle {
width: root.can-trash ? root.star : 0px;
height: root.star;
visible: root.can-trash;
Icon {
name: "trash";
// Reject and trash are the two destructive ends of this UI and
// share the palette's one hue, so the gesture reads the same
// in both places (NFR-A11Y-3: the shape carries it, not the
// colour).
ink: Theme.warn-ink;
size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
TouchArea {
enabled: root.interactive;
// Deliberately *not* grown to `touch-target`. Every other
// target here overhangs to meet FR-UI-3, but that requirement
// is about reaching a control with a thumb — it is not a
// reason to make a destructive one easier to hit by accident
// than the thing beside it. 22px plus the gap is a real target
// without reaching into ★1's band.
width: 100%;
height: 100%;
mouse-cursor: root.interactive ? MouseCursor.pointer
: MouseCursor.default;
clicked => { root.trash(); }
}
}
// The gap itself, inert — no TouchArea, so a press here does nothing
// rather than resolving to whichever neighbour is closer.
Rectangle { width: root.can-trash ? root.trash-gap : 0px; }
for n[i] in [1, 2, 3, 4, 5]: Rectangle {
width: root.star;
height: root.star;
Icon {
// Solid versus outline: the shape says it, not the colour.
name: root.rating >= n ? "star" : "star-outline";
ink: root.rating >= n ? Theme.active : Theme.ink-dim;
// Large enough to hit the difference between filled and empty
// at arm's length on a tablet; the two differ in fill, which
// needs more pixels to read than a difference in shape would.
size: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
// Grown past the drawn star to meet FR-UI-3's 44pt minimum, and
// centred on it. The overhang overlaps its neighbours, so the
// *later* star wins the shared band — which is why this is
// acceptable here: adjacent targets belong to the same control and
// a near-miss sets a rating one star out, not something unrelated.
//
// Vertical overhang spills outside the strip onto the thumbnail,
// which carries no hit area of its own — the cell's TouchArea is
// below this in z-order.
TouchArea {
enabled: root.interactive;
width: max(parent.width, Theme.touch-target);
height: max(parent.height, Theme.touch-target);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
mouse-cursor: root.interactive ? MouseCursor.pointer
: MouseCursor.default;
// Clicking the star already set clears the rating — the
// gesture every photo tool uses for "undo that", and without
// it the only way back to unrated is the keyboard.
clicked => { root.rate(root.rating == n ? 0 : n); }
}
}
}
}
// The pick/reject mark.
//
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
// a tick and a cross are distinguishable without hue, and a reject reads as a
// reject in greyscale. A reject also dims its whole cell, which is the cue
// that carries at grid scale — the mark is confirmation, not the primary
// signal.
export component FlagMark inherits Rectangle {
in property <int> flag: 0;
width: 16px;
height: 16px;
border-radius: 8px;
visible: root.flag > 0;
background: Theme.surface;
opacity: 0.92;
Icon {
name: root.flag == 2 ? "cross" : "check";
// Reject earns the one hue in the palette: it is the destructive end
// of the axis and the thing a user must not mistake for a pick.
ink: root.flag == 2 ? Theme.warn-ink : Theme.active;
size: 9px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
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 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);
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 is no longer reported here: the batch belongs to a
// window of the grid rather than to anything the user asked for, and the
// shell's bar now carries it along with every other running job.
// Whole-library indexing, which runs for far longer than one window's
// thumbnails and is reported separately so the two do not fight over the
// same line.
in property <int> sweep-done: 0;
in property <int> sweep-total: 0;
/// Pushing shards and the catalog to the server.
in property <bool> syncing: false;
property <bool> sweeping: root.sweep-total > 0 && root.sweep-done < root.sweep-total;
callback cell-clicked(int);
/// A star was clicked on a cell: row, and the rating 0..5.
callback cell-rated(int, int);
/// Whether the grid is currently listing the trash rather than the
/// library. Suppresses the per-cell trash target, which would be inert
/// there — `plan_trash` skips an already-trashed image — and offering a
/// control that does nothing is worse than offering none.
in property <bool> viewing-trash: false;
/// The trash target was clicked on a cell. Acts on that one photograph,
/// like the stars beside it — the pointer names it unambiguously, and a
/// click that quietly trashed a whole selection would be a trap. `Delete`
/// is the bulk gesture.
callback cell-trashed(int);
/// Move the selection to the trash — the `Delete` key.
callback trash-selection();
/// A rating or flag key was pressed while the grid had focus. Applies to
/// the whole selection, which is what makes rating forty frames one
/// gesture.
///
/// Rating and flag travel on one callback because they are one keystroke
/// as far as the user is concerned; Rust decodes which axis was meant.
/// `rating` is -1 where the key was a flag, and `flag` -1 where it was a
/// star, so neither axis is disturbed by a press on the other.
callback judged(int, int);
/// `F2` — rename the collection the grid is scoped to. The key lives with
/// the grid because that is what holds focus in library mode, but the
/// rename itself happens in the sidebar.
callback rename-scope();
/// Whether the collections sidebar is currently shown. The toggle for it
/// lives here rather than in the sidebar itself for the obvious reason: a
/// control inside a closed panel cannot reopen it.
in property <bool> collections-visible: true;
callback toggle-collections();
callback rescan();
/// Back to the launch screen, to change library or account.
callback change-library();
/// Open the settings page.
callback open-settings();
// --- 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);
// --- offline (FR-CAT-9) -------------------------------------------------
//
// Offline is a banner rather than a modal or an empty state, because most
// of the library still works: the shards hold the thumbnails, and rating,
// flagging, sorting and collecting are catalog writes that never touched
// the network. Only opening an un-cached original actually fails.
in property <bool> offline: false;
in property <string> offline-reason: "";
in property <string> offline-since: "";
callback retry-connection();
// --- pinning (FR-NC-6a) -----------------------------------------------
//
// Whether the collection the grid is scoped to is kept offline, and how
// far the download has got. Progress is shown because pinning a trip is
// gigabytes of transfer — a button that appeared to do nothing for twenty
// minutes would read as broken.
in property <bool> scope-pinned: false;
in property <int> pin-done: 0;
in property <int> pin-total: 0;
callback toggle-pin-scope();
/// Narrow to images whose RAW is stored locally — the ones openable now.
in property <bool> local-only: false;
in property <int> local-count: 0;
callback toggle-local-only();
/// How many images are selected, for the header's count.
in property <int> selected-count: 0;
// --- 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();
// 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.
in 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;
// 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;
}
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(); }
}
// TRACES: FR-NC-6a
// Keep this collection offline. Only offered when the grid is
// scoped to one: "pin the whole library" is a different and
// much more expensive request, and a button that meant either
// depending on invisible state would be a trap.
if root.scope-label != "": Button {
// No tick on the pinned label: `active` already inverts
// the button, which says the same thing without a symbol
// inside a string (see icons.slint).
text: root.scope-pinned ? "Pinned" : "Pin offline";
active: root.scope-pinned;
enabled: !root.scanning;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-pin-scope(); }
}
Button {
// Shares the finished index so a second device inherits it
// rather than repeating hours of range fetches.
text: root.syncing ? "Syncing…" : "Sync";
enabled: !root.syncing && !root.scanning;
y: (parent.height - self.height) / 2;
clicked => { root.sync-now(); }
}
Button {
text: "Rescan";
y: (parent.height - self.height) / 2;
visible: !root.scanning;
clicked => { root.rescan(); }
}
// Last in the row, and unconditional. The buttons before it
// come and go with what the grid is showing; settings is
// always reachable, and a control that moved as its
// neighbours appeared would be hunted for each time.
Button {
text: "Settings";
y: (parent.height - self.height) / 2;
clicked => { root.open-settings(); }
}
}
}
// --- 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 {
icon: "star";
label: n + "+";
count: root.rating-counts.length > n ? root.rating-counts[n] : -1;
active: root.filter-min-rating == n;
y: (parent.height - self.height) / 2;
// Pressing the active one clears it, so the filter is its
// own undo and "All" is not the only way back.
clicked => {
root.filter-min-rating-changed(
root.filter-min-rating == n ? 0 : n);
}
}
Rectangle { width: Theme.gap; }
FilterChip {
icon: "check";
label: "Picks";
active: root.filter-flag == 1;
y: (parent.height - self.height) / 2;
clicked => {
root.filter-flag-changed(root.filter-flag == 1 ? 0 : 1);
}
}
FilterChip {
icon: "cross";
label: "Rejects";
active: root.filter-flag == 2;
y: (parent.height - self.height) / 2;
clicked => {
root.filter-flag-changed(root.filter-flag == 2 ? 0 : 2);
}
}
Rectangle { width: Theme.gap; }
// Locally-stored originals. Always offered, not only when
// offline: "what can I actually work on right now" is a fair
// question on a slow connection too, and a control that
// appears only in the failure case is one the user has to
// discover at the worst moment.
FilterChip {
label: "On this device";
count: root.local-count;
active: root.local-only;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-local-only(); }
}
Rectangle { horizontal-stretch: 1; }
// What the filter is currently hiding. Without this a narrowed
// grid and an empty library look identical, which is the
// single most confusing state a filter can leave behind.
Caption {
text: (root.filter-min-rating > 0 || root.filter-unjudged
|| root.filter-flag > 0 || root.local-only)
? "filtered" : "";
emphasised: true;
vertical-alignment: center;
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// --- 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 `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-fraction: root.current-bucket-fraction;
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;
}
// --- walking the grid ---------------------------------
//
// The keys that make a cull possible without the mouse:
// arrows move the cursor, shift extends the selection from
// the anchor, `Return` opens what the cursor is on. The
// judgement keys above act on the selection, so walking
// with the arrows and rating as you go is one hand's work.
//
// Every one of these is `accept`ed. The `Flickable` scrolls
// on arrow keys of its own accord, and letting it would
// move the view out from under a cursor that had not
// moved — the grid is scrolled *to* the cursor instead,
// and only when the cursor leaves the viewport.
//
// The vertical steps are expressed in columns and rows,
// which only the grid knows: how far "down" is depends on
// how wide the window happens to be.
if (event.text == Key.LeftArrow) {
root.move-cursor(-1, event.modifiers.shift);
return accept;
}
if (event.text == Key.RightArrow) {
root.move-cursor(1, event.modifiers.shift);
return accept;
}
if (event.text == Key.UpArrow) {
root.move-cursor(-root.columns, event.modifiers.shift);
return accept;
}
if (event.text == Key.DownArrow) {
root.move-cursor(root.columns, event.modifiers.shift);
return accept;
}
if (event.text == Key.PageUp) {
root.move-cursor(-root.columns * root.visible-rows,
event.modifiers.shift);
return accept;
}
if (event.text == Key.PageDown) {
root.move-cursor(root.columns * root.visible-rows,
event.modifiers.shift);
return accept;
}
// A step longer than the library, clamped at the far end.
// `total` is what the grid was told the library holds, so
// this stays honest as it grows.
if (event.text == Key.Home) {
root.move-cursor(-root.total, event.modifiers.shift);
return accept;
}
if (event.text == Key.End) {
root.move-cursor(root.total, event.modifiers.shift);
return accept;
}
if (event.text == Key.Return) {
root.open-cursor();
return accept;
}
return reject;
}
}
// --- the grid -----------------------------------------------------
//
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
// properly, so dragging a cell drags the cell and dragging the
// background still flicks the grid. (This is the part a hand-rolled
// TouchArea gesture could not do — see the drag comments above.)
if root.total > 0: grid-scroll := Flickable {
// Ctrl+wheel resizes the cells; a plain wheel is declined and
// falls through to the Flickable's own scrolling. Two jobs on
// one gesture, distinguished by the modifier — the convention
// every image browser uses.
//
// Declared *first* so it sits beneath the cells in z-order:
// their own touch areas still take clicks and drags, and only
// a wheel event nothing else claimed reaches this.
zoom-catcher := TouchArea {
width: 100%;
height: parent.viewport-height;
scroll-event(e) => {
if (e.modifiers.control) {
root.zoom-cells(e.delta-y > 0 ? 1 : -1);
return accept;
}
return reject;
}
}
// Two-finger pinch, for tablet: the same gesture the timeline
// uses, applied to cell size rather than to time.
grid-pinch := ScaleRotateGestureHandler {
width: 100%;
height: 100%;
property <float> last-scale: 1.0;
started => { self.last-scale = 1.0; }
updated => {
// A quarter-step either way is enough to act on: cell
// size is continuous, unlike the timeline's discrete
// zoom levels.
if (self.scale / max(0.01, self.last-scale) > 1.15) {
root.zoom-cells(1);
self.last-scale = self.scale;
} else if (self.scale / max(0.01, self.last-scale) < 0.87) {
root.zoom-cells(-1);
self.last-scale = self.scale;
}
}
ended => { self.last-scale = 1.0; }
cancelled => { self.last-scale = 1.0; }
}
// Follow a requested position. Without this a scrub moves the
// *loaded window* while the viewport stays where it was, so
// the cells are drawn thousands of rows away and the grid
// looks empty until the user scrolls to find them.
function seek() {
self.viewport-y = -min(
max(0px, self.viewport-height - self.height),
floor(root.scroll-to / max(1, root.columns))
* (root.cell-size + Theme.gap));
}
property <int> token: root.scroll-token;
changed token => { self.seek(); }
// Keep the keyboard cursor in view, moving as little as will
// do it.
//
// Deliberately *not* `seek()`: that puts the requested row at
// the top, which is right for a scrub — the user asked to go
// to a date and expects to arrive there — and wrong for an
// arrow key, where the grid jumping a row upward on every
// press makes the row impossible to read. So a cursor already
// on screen moves nothing at all, and one that has just left
// brings in exactly its own row.
property <length> pitch: root.cell-size + Theme.gap;
property <int> cursor-row: floor(root.cursor / max(1, root.columns));
changed cursor-row => { self.reveal(); }
function reveal() {
if (root.cursor < 0) {
return;
}
let top = Theme.gap + self.cursor-row * self.pitch;
let shown = -self.viewport-y;
let bottom = max(0px, self.viewport-height - self.height);
if (top < shown) {
self.viewport-y = -min(bottom, top);
} else if (top + self.pitch > shown + self.height) {
self.viewport-y = -min(bottom, top + self.pitch - self.height);
}
}
// Also on creation, which is what returning from the develop
// view needs. `show-library` gates an `if`, so the grid is built
// anew and `token` is *initialised* to the already-bumped value
// rather than changing to it — no `changed` handler fires, and
// without this the restored position would be dropped and the
// view would sit at the top.
init => { self.seek(); }
// Sized to the **whole library**, not the loaded window. The
// scrollbar has to represent 23,971 images or there is no way to
// reach image 20,000 — dragging it must be a real address, and the
// window is swapped underneath to match.
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap;
// 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 cross on
// the mark confirms it up close.
opacity: cell.lifted ? 0.25
: (cell.flag == 2 ? 0.4 : 1.0);
animate opacity { duration: 120ms; }
}
if !cell.has-thumb: Caption {
text: cell.unavailable ? "no preview" : "…";
horizontal-alignment: center;
}
// "Already filed, in this many collections." Without it
// there is no way to tell a filed photograph from an
// unfiled one, and the user re-files what is already
// in place.
if cell.collection-count > 0: Rectangle {
x: parent.width - self.width - 4px;
y: 4px;
width: 16px;
height: 16px;
border-radius: 8px;
background: Theme.selected;
opacity: 0.9;
Text {
text: cell.collection-count;
color: Theme.ink;
font-size: 9px;
font-weight: 700;
width: 100%;
height: 100%;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// Pick or reject, top left — the opposite corner
// from the collection badge so the two never
// collide on a cell that carries both.
FlagMark {
x: 4px;
y: 4px;
flag: cell.flag;
}
}
Label {
text: cell.name;
emphasised: cell.selected;
overflow: elide;
}
}
// Selection 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); }
}
}
}
}
}
}
}
}