Files
DarkRoom/ui/dr-ui/ui/library.slint
T
dtourolle 9a24623e35 Fix the workspace build off-device
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
2026-08-09 21:15:53 +02:00

1248 lines
58 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, 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); }
}
}
}
}
}
}
}
}