// The library grid: what a scanned library looks like. // // Cells come from Rust as a windowed model, never the whole catalog — a 17k // image library must not become 17k live elements (FR-CAT-4). // // A cell with no thumbnail yet shows why rather than an empty box. On a remote // library a thumbnail is a network round trip, so "nothing there", "not // fetched yet", and "no preview in this file" are three different states and // must not look identical (FR-NC-6c). import { Theme } from "theme.slint"; import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint"; // The filing sheet lists the same rows the sidebar draws, from the same model: // two lists of collections that could disagree about what exists is one list // too many. import { CollectionRow } from "collections.slint"; // TRACES: FR-CAT-5 // One keyword in the keywording sheet, already answered against the selection. // // The three-way `coverage` is the whole reason this is a struct rather than a // list of strings. Applying a word to forty photographs where thirty already // carry it must not look like applying it to forty that carry none, and // removing one that only some of them carry must not silently claim to have // taken it off all forty. Rust computes it, because only Rust knows how big the // selection is and how many of it each word covers. export struct KeywordRow { // Row id in `keyword_terms`, or 0 for a word an image carries that the // vocabulary has no identity for yet. The sheet acts on `name`, never on // this, so a 0 costs nothing — it is here so a future rename gesture has // something to name. id: int, name: string, // 0 none of the selection, 1 some of it, 2 all of it. coverage: int, // How many of the selected photographs carry it, for the "3 of 12" that // makes `coverage: 1` a number rather than a shrug. selected-count: int, // How many photographs in the whole library carry it. Lets a word in // regular use be told from one typed once by mistake. image-count: int, } // One bar of the capture-time histogram. export struct TimelineBar { // 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet // day stays visible beside a wedding. height: float, // Bucket start, Unix seconds. What a click on this bar scrubs to. start: int, count: int, label: string, // "2024", "Mar", or empty. Non-empty only where this bucket begins a new // year or month, so the axis is labelled at boundaries rather than on // every bar. Chosen in Rust, which knows the granularity. period-label: string, } // The capture-time sidebar: the shape of the library over time, and the // primary way of moving through it. // // **Vertical, and a sidebar rather than a strip**, because a scroll position is // a relative quantity and a date is an absolute one. The grid's scrollbar says // how far through you are; this says *when* you are. // // Three gestures, in the darktable idiom: // // - **click or drag** — scrub the grid to that instant // - **drag with the middle button, or shift-drag** — pan the visible span // - **wheel** — zoom, which changes the bucket size Rust picks // - **drag either end of the range band** — narrow the grid to a period // // # One hit area, not one per bar // // An earlier version gave every bar its own `TouchArea`. With a few hundred // buckets that is a few hundred overlapping hit regions, and a drag is // delivered to whichever bar the press *started* on rather than the one under // the cursor — so scrubbing jumped and stuttered. There is exactly one // `TouchArea` here and the bucket is computed from the pointer's position. export component Timeline inherits Rectangle { in property <[TimelineBar]> bars; in property 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 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 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 anchored: false; /// Scrub to a fraction along the visible span. Rust turns it into an /// instant, interpolating within a bucket rather than snapping to its edge. callback scrub-to(float); callback pan(float); callback zoom(int); /// Pinch: a ratio above 1 spreads the fingers (zoom in), below 1 pinches /// them together. Continuous, unlike the wheel's discrete steps. callback pinch(float); /// TRACES: FR-CAT-6 /// The chosen date range, as fractions of the drawn span — the same /// coordinates `scrub-to` reports and `current-fraction` is drawn in, so /// the band, the marker and a click cannot disagree about where a date /// sits. Negative in both when no range is set. in property range-from: -1; in property range-to: -1; /// An end of the range was dragged, and where both ends now are. /// /// Sent on release rather than continuously: each one of these re-runs the /// grid's query and reloads its window, and doing that per frame of a drag /// is how a filter turns into a stutter. The band tracks the finger /// meanwhile — that is what `live-from` and `live-to` are for. callback range-changed(float, float); width: 96px; background: Theme.surface; property hovered: -1; property track-top: 22px; property track-height: max(1px, self.height - self.track-top - 6px); property slot: root.track-height / max(1, root.bars.length); property has-range: root.range-from >= 0 && root.range-to >= 0; /// Which end is under the finger: 0 neither, 1 the early end, 2 the late. property grabbed: 0; /// The ends as the drag has them, before Rust has been told. Drawing from /// these is what lets the band follow the finger without the grid being /// re-queried on every frame. property live-from; property live-to; property shown-from: root.grabbed > 0 ? root.live-from : root.range-from; property shown-to: root.grabbed > 0 ? root.live-to : root.range-to; /// How near a press has to land to take hold of an end, as a fraction of /// the track. Sized for a finger rather than a pointer: this control /// exists because on a phone the range could only be typed (FR-UI-2). property grab: 22px / root.track-height; /// Bucket index under a y coordinate, clamped to the ends so a drag that /// leaves the widget still scrubs to the nearest bucket rather than /// stopping dead. function bucket-at(y: length) -> int { return clamp(floor((y - root.track-top) / max(1px, root.slot)), 0, max(0, root.bars.length - 1)); } /// Position along the axis as a fraction, 0 at the first bucket's start and /// 1 at the last one's end. /// /// **Fractional rather than a bucket index.** Snapping to whole buckets /// makes a slow drag feel dead — the pointer moves and nothing happens /// until it crosses a boundary, then the grid jumps a whole month. Rust /// interpolates an instant from this, so the grid tracks the finger. function fraction-at(y: length) -> float { return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0); } /// Where a fraction sits on the track. The inverse of `fraction-at`, and /// it has to stay so: the band is drawn with this and dragged with that. function y-at(f: float) -> length { return root.track-top + clamp(f, 0.0, 1.0) * root.track-height; } /// Which end of the range a press at `y` takes hold of, 0 for neither. /// /// Compared in fractions rather than pixels because the ends are held as /// fractions — converting them to a y here and back to a fraction on the /// drag would be a second copy of the same arithmetic to keep in step. function handle-at(y: length) -> int { if (!root.has-range) { return 0; } if (min(abs(root.fraction-at(y) - root.shown-from), abs(root.fraction-at(y) - root.shown-to)) > root.grab) { return 0; } // A range narrowed to a day draws one line, not two. Taking the // nearer end there would always answer "the early one" and leave the // late end impossible to pull back out; which side of the line the // press landed on is what the user meant by it. if (abs(root.shown-from - root.shown-to) < 0.001) { return root.fraction-at(y) > root.shown-from ? 2 : 1; } return abs(root.fraction-at(y) - root.shown-from) < abs(root.fraction-at(y) - root.shown-to) ? 1 : 2; } /// Move the held end to `y`. /// /// The ends are allowed to cross. Dragging one past the other is a normal /// way to say "no, from *here* instead", and Rust puts the pair back in /// order — stopping the finger dead at the other end would make a range /// that has been narrowed too far harder to correct than to start again. function drag-end(y: length) { if (root.grabbed == 1) { root.live-from = root.fraction-at(y); } else { root.live-to = root.fraction-at(y); } } // Header: the hovered bucket, else the whole span. Caption { x: 8px; y: 4px; width: parent.width - 16px; text: root.hovered >= 0 && root.hovered < root.bars.length ? root.bars[root.hovered].label + " · " + root.bars[root.hovered].count : root.range-label; emphasised: root.hovered >= 0; overflow: elide; } // The bars. Purely visual — every gesture is handled by the single // TouchArea below, which sits above them. for bar[i] in root.bars: Rectangle { // Bars grow from the *left* edge, so the axis reads like a timeline // turned on its side and the labels have room on the right. x: 0; y: root.track-top + i * root.slot; width: max(1px, (parent.width - 34px) * bar.height); height: max(1px, root.slot - 1px); background: bar.start == root.current-start ? Theme.active : (root.hovered == i ? Theme.ink-dim : Theme.ink-faint); } // Year and month labels down the right-hand edge. // // Drawn only where a bar *starts* a new period, so a month-bucketed view // labels each January rather than repeating the year on every bar. The // label text itself is chosen in Rust, which knows the granularity; an // empty string means "no boundary here". for bar[i] in root.bars: Text { x: parent.width - 32px; y: root.track-top + i * root.slot - 5px; width: 30px; text: bar.period-label; color: Theme.ink-faint; font-size: Theme.text-sm; visible: bar.period-label != ""; } // Where the grid currently sits. Rests at the midpoint until the user has // actually chosen a position. Rectangle { x: 0; width: parent.width; height: 1px; background: Theme.active; opacity: root.anchored ? 1.0 : 0.35; y: root.anchored && root.current-fraction >= 0 ? root.y-at(root.current-fraction) : root.track-top + root.track-height / 2; } // --- the chosen date range ------------------------------------------ // // Drawn *on* the axis, and dragged there. The two typed date fields under // the filter chips were the only way to state a range, and on a phone they // were barely a way at all: `YYYY-MM-DD` keyed into a 108px field behind a // soft keyboard, to name a day that is already drawn on the axis a thumb // away. The other route — zoom the axis to a period, then "limit to range" // — needs a wheel to zoom and a middle button to pan, and a touch screen // has neither. // // The axis keeps its full extent while a range is on (see // `refresh_timeline`, and `Filter::without_date_range`). It has to: the // bars outside the band are what the range is widened back *into*, and an // axis that redrew itself to the band would move the ground under the very // handles doing the narrowing. if root.has-range: Rectangle { // Outside the range, held back rather than hidden — those bars are // still the shape of the library, and still where the band is going // next. Starts at the track, so the header line stays legible. Rectangle { x: 0; y: root.track-top; width: parent.width; height: max(0px, root.y-at(min(root.shown-from, root.shown-to)) - root.track-top); background: Theme.surface; opacity: 0.72; } Rectangle { x: 0; y: root.y-at(max(root.shown-from, root.shown-to)); width: parent.width; height: max(0px, parent.height - self.y); background: Theme.surface; opacity: 0.72; } // The ends themselves: a line across the axis with a grip at the left, // where the bars start. The right-hand strip carries the year and // month labels, and a grip there would sit on top of them. for edge[i] in [0, 1]: Rectangle { x: 0; y: root.y-at(i == 0 ? root.shown-from : root.shown-to) - 1px; width: parent.width; height: 2px; background: Theme.active; Rectangle { x: 2px; y: -5px; width: 18px; height: 12px; border-radius: 3px; background: Theme.active; } } } // The single hit area. Everything above is inert. // Two-finger pinch, for tablet. There is no wheel there, so without this // the axis could only be zoomed by a control a finger cannot reach. // // `scale` is cumulative from 1.0 for the whole gesture, so the delta since // the last update is what maps onto a zoom step — otherwise a slow spread // would apply its total repeatedly and shoot straight to full zoom. pinch := ScaleRotateGestureHandler { width: 100%; height: 100%; property 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 press-y; property panning; moved => { if (root.grabbed > 0) { // The band follows the finger. The grid hears about it on // release — see `range-changed`. root.drag-end(self.mouse-y); } else if (self.panning) { // Fractional, so a slow drag moves the view continuously // rather than sitting still until it crosses a bucket edge. root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height)); self.press-y = self.mouse-y; } else if (self.pressed && root.bars.length > 0) { root.scrub-to(root.fraction-at(self.mouse-y)); } root.hovered = root.bars.length > 0 ? root.bucket-at(self.mouse-y) : -1; } pointer-event(e) => { if (e.kind == PointerEventKind.down) { self.press-y = self.mouse-y; // Middle button pans. Shift is not consulted here: the // modifier state belongs to the key handler, not the pointer // event, and a middle-drag is the unambiguous gesture. self.panning = e.button == PointerEventButton.middle; // An end of the range first, if the press landed on one: a // press within a finger's width of a handle means the handle, // not the scrub it would otherwise have been. root.grabbed = self.panning ? 0 : root.handle-at(self.mouse-y); if (root.grabbed > 0) { root.live-from = root.range-from; root.live-to = root.range-to; } else if (!self.panning && root.bars.length > 0) { root.scrub-to(root.fraction-at(self.mouse-y)); } } if (e.kind == PointerEventKind.up) { self.panning = false; if (root.grabbed > 0) { root.grabbed = 0; root.range-changed(root.live-from, root.live-to); } } // A cancelled gesture — the system taking the touch for a // back-swipe, most often — is not a range the user stated. The // band goes back to where Rust still has it. if (e.kind == PointerEventKind.cancel) { self.panning = false; root.grabbed = 0; } } scroll-event(e) => { // Wheel zooms rather than scrolls: the sidebar is an axis, not a // list, and a scroll gesture over it means "show me more or less // time" rather than "move down". root.zoom(e.delta-y > 0 ? 1 : -1); return accept; } changed has-hover => { if (!self.has-hover) { root.hovered = -1; } } } } /// One person the grid is narrowed to, as drawn on the filter bar. export struct PersonChip { id: int, name: string, } export struct LibraryCell { name: string, // Non-empty on the first cell of a new month, e.g. "August 2026". The grid // is ordered by capture time, so these are the only place the date is // legible without consulting the sidebar — a wall of thumbnails otherwise // gives no sense of when you are. period-heading: string, // Empty until a fetch lands. `has-thumb` disambiguates, because Slint // cannot test an image against null. thumbnail: image, has-thumb: bool, // A fetch that completed with no usable preview. Distinct from pending. unavailable: bool, // Part of the current selection. Selection is what a drag carries, so this // has to be per-cell state rather than a single "current" index. selected: bool, // The cell a shift-click would extend *from* — the last one plainly // clicked. Marked because otherwise the gesture has a hidden operand: the // user picks the far end of a run with no way to see which end the range // is being measured from, and a range that comes out wrong gives no clue // why. At most one cell in the library carries this, and often none of // them is on screen — which is itself the answer to "why did that select // so much". anchor: bool, // How many collections this image belongs to. An image can be in many at // once, and without a cue the grid gives no hint that a photograph has // already been filed — the user re-files it, or hunts for where it went. collection-count: int, // This cell is one of the images currently being dragged. It reads as // *lifted out*: desaturated and shrunk in place, so the grid shows where // the photographs came from while the cursor shows them in full colour. lifted: bool, // Stars, 0..5. Zero is *unrated* — a state of its own, not a low score, // and what "filter to unjudged" selects (FR-CULL-4). rating: int, // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a // four-star frame is a normal thing to do mid-cull. flag: int, } // The photo roll: the grid's loaded window along the foot of the develop view. // // # Why this exists // // Develop opens *one* photograph. `index` and `total` are pinned to "1 of 1" // on the way in, because the grid hands over a path and nothing else, so the // only way to reach the next frame was to go back to the library, find where // you were, and tap again. That is fine once and intolerable through a set of // forty — which is precisely the situation the develop view exists for. // // # The gesture // // A swipe up from the bottom edge brings it out, a swipe down puts it away: // the sheet gesture, already in the hands of anyone who has used a phone. // A gesture with no visible counterpart is a feature only its author knows // about (FR-UI-4), so a handle is drawn at the edge and is a button in its own // right — which is also what gives a pointer, with no swipe to make, a way in. // // The handler wraps the strip rather than sitting over or under it. That is // what `SwipeGestureHandler` is built for: it delays a press the way a // Flickable does, forwards it to the children if no swipe develops, and claims // it once one does. A tap therefore reaches the thumbnail and a drag does not. // // It covers only the band along the bottom, never the whole canvas — above // that band a drag belongs to the photograph, for panning and for the crop. export component PhotoRoll inherits Rectangle { in property <[LibraryCell]> cells; /// Which row of the loaded window is open, so it can be marked. `-1` when /// the open photograph is not in the window at all, which is the honest /// answer after a scrub — the roll shows where you are, and sometimes the /// answer is "not here". in property current: -1; in-out property open: false; /// A thumbnail was chosen. The row within the loaded window, matching what /// a cell click reports. callback pick(int); property strip-height: 108px; /// The band of canvas left grabbable when the roll is away. A thumb's /// worth, and no more: it is taken off the bottom of the photograph. property reach: 28px; property thumb: 92px; property pad: 6px; background: transparent; swipe := SwipeGestureHandler { width: 100%; height: root.strip-height + root.reach; y: parent.height - self.height; handle-swipe-up: !root.open; handle-swipe-down: root.open; // Direction decides, not distance: the handler has already applied its // own threshold by the time this fires, and re-testing the travel here // would mean a swipe that qualified as a swipe still did nothing. swiped => { root.open = self.current-position.y < self.pressed-position.y; } // --- the strip ------------------------------------------------ // // Slid out of view rather than removed. An `if` would have it appear // fully formed at the bottom of the screen instead of arriving from // the edge, and would leave nothing for the animation to act on. strip := Rectangle { width: 100%; height: root.strip-height; y: root.open ? root.reach : parent.height; animate y { duration: 180ms; easing: ease-out; } background: Theme.surface; clip: true; Rectangle { width: 100%; height: 1px; background: Theme.rule; } roll := Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, root.cells.length * (root.thumb + root.pad) + root.pad); // Bring the open photograph into view — on opening, and when // the roll itself moves the selection along. Without it a pick // near the end of the window scrolls back to the start on the // next reveal, and the mark the strip exists to show is off // the edge of it. function reveal() { if (root.current < 0) { return; } let left = root.pad + root.current * (root.thumb + root.pad); let shown = -self.viewport-x; let right = max(0px, self.viewport-width - self.width); if (left < shown) { self.viewport-x = -min(right, left); } else if (left + root.thumb > shown + self.width) { self.viewport-x = -min(right, left + root.thumb - self.width); } } property mark: root.current; changed mark => { self.reveal(); } property shown: root.open; changed shown => { if (self.shown) { self.reveal(); } } for cell[i] in root.cells: Rectangle { x: root.pad + i * (root.thumb + root.pad); y: root.pad; width: root.thumb; height: parent.height - 2 * root.pad; background: Theme.ground; border-radius: Theme.radius; // The one that is open, marked the way the grid marks a // selection so the two read as the same idea. border-width: i == root.current ? 2px : 0px; border-color: Theme.selected-ring; clip: true; Image { width: 100%; height: 100%; source: cell.thumbnail; image-fit: contain; visible: cell.has-thumb; } TouchArea { mouse-cursor: pointer; clicked => { root.pick(i); } } } } } // --- the handle ----------------------------------------------- // // Rides on the strip's top edge, so it is in the same place relative to // the roll whether it is in or out, and it is the affordance that stops // the swipe being folklore. Rectangle { width: 84px; height: root.reach; x: (parent.width - self.width) / 2; y: strip.y - self.height; animate y { duration: 180ms; easing: ease-out; } background: Theme.surface; border-radius: Theme.radius; Rectangle { width: 32px; height: 3px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.ink-dim; border-radius: 2px; } TouchArea { mouse-cursor: pointer; clicked => { root.open = !root.open; } } } } } // A row of five stars, readable at a glance and clickable to set a rating. // // **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids // status by hue alone, and the palette is achromatic anyway — so a set star is // a solid star at `active` and an unset one is an outline at `ink-faint`. The // two differ in both shape and luminance, which survives greyscale and low // vision alike. // // **Unrated draws nothing until hovered.** A grid of 120 cells each showing // five empty stars is a wall of chrome competing with the photographs; a // freshly scanned library would look like a spreadsheet. The strip appears on // hover, so an unjudged cell is quiet and a judged one is legible from across // the room. export component StarStrip inherits Rectangle { in property 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 show-empty: false; /// Whether clicking sets a rating. False in a read-only context. in property 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 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 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 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 flag: 0; width: 16px; height: 16px; border-radius: 8px; visible: root.flag > 0; background: Theme.surface; opacity: 0.92; Icon { name: root.flag == 2 ? "cross" : "check"; // Reject earns the one hue in the palette: it is the destructive end // of the axis and the thing a user must not mistake for a pick. ink: root.flag == 2 ? Theme.warn-ink : Theme.active; size: 9px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } } // The header's action buttons, in one place so they can be drawn in two. // // A tablet in portrait is 768 logical pixels wide, and the header wants a // title, three status readouts and six buttons. Laid out in one row they all // shrink to their minimum and elide — "Change library" arrives as "Change // li…" — and the row still overflows its own width. Slint's HorizontalLayout // has no wrap and no overflow, so the row has to be told what to drop. // // Extracted rather than duplicated because the alternative is six buttons // written twice with their visibility rules and callbacks copied, and the // copy that gets forgotten is the one behind the disclosure nobody opens // while testing. component HeaderActions inherits HorizontalLayout { in property scope-label; in property selected-count: 0; /// TRACES: FR-UI-2 | FR-UI-4 /// Whether taps are selecting rather than opening. The non-gesture half of /// touch multi-selection: the long press is the quick way in, and this is /// the way that can be *found*. in property select-mode: false; in property scanning: false; in property syncing: false; in property scope-pinned: false; /// TRACES: FR-DEV-6 /// Whether settings have been copied, and what pasting them would apply. in property settings-armed: false; in property settings-summary; /// TRACES: FR-EXP-7 /// Whether a batch is running, and where its files are going. in property exporting: false; in property export-to-server: false; /// Centres each button in a 44px header. Off in the disclosure row, which /// is sized to its content. in property centred: true; in property row-height: 44px; callback export-selection(); callback cancel-export(); callback paste-settings-to-selection(); callback remove-from-collection(); /// Open the sheet that files the selection in a collection. callback add-to-collection(); /// TRACES: FR-CAT-5 /// Open the sheet that keywords the selection. callback add-keyword(); callback toggle-select-mode(); callback change-library(); callback toggle-pin-scope(); callback sync-now(); /// TRACES: FR-CAT-10 /// Whether this platform can reach a card at all (`dr_plat::imports_supported`). /// /// Hidden rather than disabled, unlike the buttons above that come and go /// with the selection: those are unavailable *now* and will be available /// in a moment, where this one never will be on this device. A permanently /// disabled control teaches the reader that the row lies. in property can-import: false; callback rescan(); callback open-import(); callback open-settings(); callback open-people(); spacing: Theme.gap; // TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7 // Selection mode, first in the row because it is what the rest of the row // acts on: every button after this one appears once something is selected. // // It exists at all because the desktop's way in — ctrl-click, shift-click — // has no touch equivalent, and the drag that files a photograph in a // collection needs a *selection* before it can carry more than one. On a // tablet the long press does this faster; a gesture with no visible // counterpart is a feature only its author knows about (FR-UI-4). // // "Done" rather than "Selecting": the label should say what pressing it // does, and the inverted fill already says which state we are in. Button { text: root.select-mode ? "Done" : "Select"; active: root.select-mode; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.toggle-select-mode(); } } // TRACES: FR-CAT-7 | FR-UI-4 // File the selection in a collection without dragging it there. The drag // is the faster gesture with a pointer and impossible with one finger on a // grid that scrolls, which is the whole reason this button exists. if root.selected-count > 0: Button { text: "Add to collection"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.add-to-collection(); } } // TRACES: FR-CAT-5 | FR-CAT-6 // Keyword the selection. Beside "Add to collection" because they are the // same thought — these photographs are *of* something, and they belong // *with* something — and appearing under the same condition, because // neither means anything without a selection to act on. if root.selected-count > 0: Button { text: "Keywords"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.add-keyword(); } } // TRACES: FR-DEV-6 // Batch-apply the copied settings. Shown only with both a selection and a // clipboard, because it is meaningless without either — and because a // permanently visible button that is usually disabled teaches the user to // stop reading this row. // // The count is in the label rather than in a confirmation: this writes to // every selected image, and "Paste to 40" said before the click is worth // more than a dialogue asking the same question after it. if root.selected-count > 0 && root.settings-armed: Button { text: "Paste to " + root.selected-count; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.paste-settings-to-selection(); } } // TRACES: FR-EXP-7 | NFR-ARCH-3 // Export the selection, and stop the batch that is running. // // One button doing both, because they are the same thought a moment apart // and a separate cancel would have to appear from somewhere — shifting the // row under the pointer at the exact moment the user is reaching for it. // // It stays while a batch runs whatever the selection has since become: the // run is what the button now refers to, and a batch of three hundred that // could only be stopped by not changing the selection would be a trap. // // The count is in the label rather than behind a confirmation, exactly as // the paste above puts it there: "Export 40" read before the click is worth // more than a dialogue asking the same question after it. if root.selected-count > 0 || root.exporting: Button { text: root.exporting ? "Cancel export" : (root.export-to-server ? "Export " + root.selected-count + " to Nextcloud" : "Export " + root.selected-count); active: root.exporting; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { if (root.exporting) { root.cancel-export(); } else { root.export-selection(); } } } // Removing from a collection is only meaningful while the grid is scoped // to one. Offering it unscoped would invite the reading "remove from the // library", which nothing here does. if root.scope-label != "" && root.selected-count > 0: Button { text: "Remove from collection"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.remove-from-collection(); } } if !root.scanning: Button { text: "Change library"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.change-library(); } } // TRACES: FR-NC-6a // Keep this collection offline. Only offered when the grid is scoped to // one: "pin the whole library" is a different and much more expensive // request, and a button that meant either depending on invisible state // would be a trap. if root.scope-label != "": Button { // No tick on the kept label: `active` already inverts the button, // which says the same thing without a symbol inside a string. // // It opens the same question the sidebar's tray and a held row open, // rather than pinning outright — see the note on the callback in // `library_ui::wire`. text: root.scope-pinned ? "Kept offline" : "Keep offline"; active: root.scope-pinned; enabled: !root.scanning; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.toggle-pin-scope(); } } Button { // Shares the finished index so a second device inherits it rather // than repeating hours of range fetches. text: root.syncing ? "Syncing…" : "Sync"; enabled: !root.syncing && !root.scanning; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.sync-now(); } } if !root.scanning: Button { text: "Rescan"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.rescan(); } } // TRACES: FR-CAT-10 // Beside Rescan rather than beside Settings: both put photographs into the // library, where Settings is about the application. Hidden during a scan // for the same reason Rescan is — an import writes files the running scan // would then half-see, and the two disagreeing about what is in a folder // is a worse outcome than waiting. if !root.scanning && root.can-import: Button { text: "Import"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-import(); } } // A library action rather than a setting, so it sits with the others and // ahead of Settings, which the comment below keeps last. Button { text: "Identity"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-people(); } } // Last in the row, and unconditional. The buttons before it come and go // with what the grid is showing; settings is always reachable, and a // control that moved as its neighbours appeared would be hunted for each // time. Button { text: "Settings"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-settings(); } } } export component LibraryGrid inherits Rectangle { /// TRACES: FR-UI-1 /// The layout class, from the window width rather than the device. /// /// Compact moves the header's actions behind a disclosure; see /// [`HeaderActions`]. Defaults to expanded so a caller that forgets to /// pass it gets the desktop layout rather than a hidden toolbar. in property expanded: true; /// Whether the compact action row is showing. Local to the grid: it is a /// disclosure, not a preference, and it should close itself the moment /// the window is wide enough not to need it. property actions-open: false; changed expanded => { if (root.expanded) { root.actions-open = false; } } in property <[LibraryCell]> cells; in property total: 0; in property scanning: false; in property scan-status: ""; in property 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 root-label: ""; // --- capture-time scrubber --- in property <[TimelineBar]> timeline; in property timeline-label: ""; /// Dates spanned by the cells currently shown. in property window-label: ""; in property 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 scroll-to: 0; in property 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 current-bucket: 0; in property 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 timeline-anchored: false; /// A fraction along the visible span, not a bucket index — the timeline /// interpolates so a slow drag tracks the finger rather than snapping. callback scrub-fraction(float); callback timeline-pan(float); callback timeline-zoom(int); /// Pinch ratio: above 1 spreads (zoom in), below 1 pinches (zoom out). callback timeline-pinch(float); /// Ctrl+wheel or pinch over the grid: resize the cells. A signed step, /// not a size, so Rust owns the bounds. callback zoom-cells(int); /// A pinch step: the ratio since the last update, above 1 spreading. The /// continuous counterpart of `zoom-cells`, which the wheel steps. callback pinch-cells(float); /// A pinch has begun, so the press that opened it was not a press. callback pinch-started(); callback columns-changed(int); callback sync-now(); /// The grid scrolled: the first visible image's ordinal in the library. /// Rust answers by loading the window around that position. callback scrolled(int); /// How many cells the viewport can show at once changed — a resize, or a /// column-count change. /// /// The *screenful*, not the window to load. How many screenfuls are held /// around it, and how close the view may come to an edge of them before /// the window moves, are one decision and it is made in Rust — split /// across the two languages they drifted apart, and the gap between them /// was rows on screen that no loaded cell covered. callback viewport-cells-changed(int); // Thumbnail progress is no longer reported here: the batch belongs to a // window of the grid rather than to anything the user asked for, and the // shell's bar now carries it along with every other running job. // Whole-library indexing, which runs for far longer than one window's // thumbnails and is reported separately so the two do not fight over the // same line. in property sweep-done: 0; in property sweep-total: 0; /// Pushing shards and the catalog to the server. in property syncing: false; property 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 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 collections-visible: true; callback toggle-collections(); callback rescan(); /// Back to the launch screen, to change library or account. callback change-library(); /// Open the settings page. /// TRACES: FR-CAT-10 /// Whether this platform can import at all. Forwarded to the header, which /// hides the button rather than disabling it. in property can-import: false; callback open-import(); callback open-settings(); callback open-people(); // --- selection and drag --- // // A click selects; ctrl-click adds to the selection; shift-click extends a // range. Dragging a selected cell carries the whole selection, which is // what makes "put these forty photographs in that collection" one gesture. // // The drag itself is Slint's own `DragArea`, not a hand-rolled gesture. The // first attempt here tracked presses and travel through a `TouchArea` and // failed for a reason worth recording: an interactive `Flickable` claims any // drag that begins inside it for scrolling, cancelling the child // TouchArea's press, so the gesture could never leave the grid. `DragArea` // is arbitrated properly against the Flickable, keeps the pointer capture // across component boundaries, and draws its own cursor overlay — which is // also why there is no badge position to compute here any more. /// Modifier state at press time, so Rust can decide replace / add / extend /// without the .slint file encoding the selection policy. callback cell-pressed(int, bool, bool); /// TRACES: FR-UI-2 | FR-UI-4 /// The press on a cell ended — lifted, or taken away by the Flickable when /// the finger travelled. Cancels the long-press timer that would otherwise /// turn a scroll into a selection. Rust owns that timer: Slint has no /// long-press gesture, and a hand-rolled one here would need a `Timer` per /// visible cell. callback cell-press-ended(); /// TRACES: FR-UI-2 | FR-UI-4 /// Two taps on the same cell. In selection mode this is the touch form of /// shift-click: everything from where the selection started to here. Rust /// decides — outside that mode a double tap is two ordinary clicks and the /// first has already opened the image. callback cell-double-clicked(int); /// TRACES: FR-UI-2 | FR-UI-4 /// Whether a tap selects rather than opens. /// /// Held in Rust beside the selection it modifies, so the long press and the /// header's button are two doors into one state rather than two states that /// can disagree. In this mode a plain tap toggles a cell — exactly what /// ctrl-click does with a pointer, which is why the press below passes it /// as the ctrl flag rather than as a third selection policy. in property select-mode: false; callback toggle-select-mode(); /// TRACES: FR-CAT-5 /// Drop the selection without leaving select mode. callback clear-selection(); /// TRACES: FR-CAT-5 /// Make a new collection holding exactly what is selected. callback collection-from-selection(); /// 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 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 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 filter-unjudged: false; /// 0 no flag filter, 1 picks only, 2 rejects only. in property filter-flag: 0; /// How many images sit at each star count, index 0 being unrated. Shown /// on the filter buttons so the user can see there is something behind a /// filter before narrowing to it — a filter that silently empties the /// grid reads as broken. in property <[int]> rating-counts; /// Whose photographs the grid is narrowed to — one chip each, so a /// selection of three can be taken apart one person at a time. in property <[PersonChip]> filter-people; /// Whether those people are an intersection rather than a union. in property filter-people-all: false; callback filter-person-cleared(int); callback filter-people-mode-toggled(); callback filter-min-rating-changed(int); callback filter-unjudged-toggled(bool); callback filter-flag-changed(int); // --- offline (FR-CAT-9) ------------------------------------------------- // // Offline is a banner rather than a modal or an empty state, because most // of the library still works: the shards hold the thumbnails, and rating, // flagging, sorting and collecting are catalog writes that never touched // the network. Only opening an un-cached original actually fails. in property offline: false; in property offline-reason: ""; in property 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 scope-pinned: false; in property pin-done: 0; in property pin-total: 0; callback toggle-pin-scope(); /// Narrow to images whose RAW is stored locally — the ones openable now. in property local-only: false; in property local-count: 0; callback toggle-local-only(); /// Whether the grid is narrowed to the timeline's visible span. in property range-active: false; callback toggle-date-range(); /// TRACES: FR-CAT-6 /// The same range as fractions of the span the axis is drawn over, for /// the band on the timeline. Negative in both when there is none. /// /// A second representation of one range, which is worth it: the fields /// below are how a date is *stated* and the band is how a period is /// *found*, and neither is a good way to do the other's job. Both come /// from the one filter in Rust, so they cannot drift apart. in property range-from-fraction: -1; in property range-to-fraction: -1; callback timeline-range-changed(float, float); /// The ends of the range, as `YYYY-MM-DD`, when one is set. /// /// Strings rather than instants because this is what the user types and /// what they read back. Rust parses them and refuses what is not a date; /// `range-invalid` is how that refusal reaches the field, since a filter /// that silently ignored a typo would show an empty grid and no reason. in property range-from; in property range-to; in property range-invalid: false; /// Both ends at once: they are one range, and applying half of an edit /// would filter to a span the user never asked for. callback range-edited(string, string); /// How many images are selected, for the header's count. in property selected-count: 0; // TRACES: FR-DEV-6 // Batch-applying copied develop settings to the selection. The clipboard // itself belongs to the window — a copy is taken in the develop view and // pasted here — so the grid only reports what it has and asks. in property settings-armed: false; in property settings-summary; callback paste-settings-to-selection(); // TRACES: FR-EXP-7 // Exporting the selection. The grid owns neither the settings that decide // where the files go nor the worker that writes them — it reports what is // selected and asks, exactly as it does for a paste. in property exporting: false; in property export-to-server: false; callback export-selection(); callback cancel-export(); // --- the keyboard cursor ------------------------------------------------ // // Where the keyboard is in the library, as an **image ordinal** — not a // row of the loaded window, which names a different photograph after every // scroll. Rust owns it, because clamping it needs the library's length and // moving it may have to swap the window underneath. // // -1 before the user has taken hold of it, so a fresh grid draws no cursor // and the first arrow press picks up where the view already is rather than // teleporting to image zero. in property 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 scope-label: ""; /// Take the selection out of the collection currently being shown. Only /// offered when the grid is scoped to one — "remove from library" is not a /// thing this button does. callback remove-from-collection(); // --- filing the selection (FR-CAT-7, FR-UI-4) --------------------------- // // The drag onto the sidebar is the fast way to file photographs, and it is // a pointer gesture: a one-finger drag beginning inside the grid belongs to // the Flickable that scrolls it, which is the arbitration described at the // top of `collections_ui.rs` working exactly as it should. So touch needs a // way in that is not a drag, and that is this sheet. // // The rows are the sidebar's own model, passed through rather than queried // again: two lists of collections is one list too many, and the one that // goes stale is always the one nobody is looking at. in property <[CollectionRow]> collections; /// Whether the sheet is up. Local, because it is a disclosure rather than a /// preference — nothing outside this file needs to know it is open, and /// what closes it is choosing a collection or dismissing it. property filing: false; /// Whether the images should *leave* the collection being shown, rather /// than being filed in a second one as well. Only meaningful while scoped, /// and reset every time the sheet opens: a destructive default that /// remembered itself between uses is how photographs go missing. property filing-moves: false; /// File the selection: the target collection's id, and whether to take it /// out of the one currently being shown. callback file-in-collection(int, bool); // --- keywording the selection (FR-CAT-5, FR-CAT-6) ---------------------- // // The catalog has been searchable by keyword since it existed and there was // never anywhere to type one. This sheet is that place, and it sits beside // the filing sheet above because the two are the same gesture applied to // two different kinds of label — pick the photographs, then say what they // are — and a user who has learnt one should not have to learn the other. // // Assign and unassign travel by **name**, not by id. A word typed into the // field and a word tapped in the list are then one path through Rust rather // than two, and the sheet does not have to invent an id for a keyword that // does not exist yet. /// The vocabulary, already answered against the current selection. in property <[KeywordRow]> keywords; /// The sheet is opening: Rust answers by refreshing `keywords` against /// whatever is selected *now*. /// /// Pulled on open rather than pushed on every selection change, because the /// selection changes on every arrow key and the sheet is shut for almost /// all of them — recomputing coverage over a forty-image selection for a /// panel nobody is looking at is work the grid cannot afford. callback keywords-opened(); callback assign-keyword(string); callback unassign-keyword(string); /// Whether the sheet is up. Local, for the same reason `filing` is: it is a /// disclosure rather than a preference, and what closes it is dismissing it. property keywording: false; // Cell geometry. Columns are derived from the available width so the grid // reflows with the window rather than fixing a count (FR-UI-1). // Zoomable, so the grid serves both jobs: fewer, larger images for // judging one, and more, smaller ones for finding one. Driven from Rust so // the value survives a scope change and the thumbnail class can follow it. /// The size class the user chose. What is actually drawn is `cell-size` /// below, which is this capped to what the grid can hold. in property requested-cell-size: 180px; // --- what the grid is measured against --------------------------------- // // **`grid-area`, not `self`.** Every quantity below describes the box the // cells are actually drawn in, and that box is *not* this component: the // capture-time axis is a sibling of it, 96px wide, and the header sits // above it. Measuring against `self.width` therefore counted the timeline // as room for thumbnails and fitted one more column than there was space // for — the last column ran off the right-hand edge, clipped by the // Flickable, with no way to scroll sideways to it. At 180px cells that is // a whole column lost on a phone in portrait, where 96px of 400 is a // quarter of the screen. // // Reading a descendant's geometry is safe here because nothing derived // from it feeds back into the layout: the cells are placed absolutely // inside the Flickable, and a Flickable's own layout constraints are a // bare `stretch: 1` that its viewport cannot influence. That is the loop // the comments on `panel-visible` in app.slint warn about, and this is not // an instance of it. /// How many columns of roughly the requested size the grid holds. /// /// **Rounded, not floored.** The size class is a request, not a /// measurement — what the user is choosing is "about this big" — so a /// width that is nine tenths of the way to another column should take it /// rather than leave nearly a whole column of dead space at the edge. /// /// A minimum of one, so a grid narrower than a single cell still draws /// that cell rather than none. property columns: max(1, round((grid-area.width - Theme.gap) / (root.requested-cell-size + Theme.gap))); /// The drawn cell: the width, divided exactly. /// /// **The tiles fill the grid.** They used to be drawn at whatever the size /// class asked for and the remainder left as a bare strip down the /// right-hand side — up to one cell short of a full column of nothing, /// which on a phone is a quarter of the screen. Worse, the column count /// was derived from that same fixed size, so the two disagreed about how /// much room there was and the last column could start inside the viewport /// and end outside it. /// /// Solving for the cell instead removes both faults at once: pick how many /// columns to draw, then make them share the width. `columns` cells and /// the `columns + 1` gaps around them come to exactly `grid-area.width`, /// so there is no remainder to strand and nothing can overhang. /// /// The size class still decides what the user gets — it is what `columns` /// is chosen from — it just no longer dictates the pixel, so the cell /// flexes by a few percent either way to make the row come out even. property cell-size: max(64px, (grid-area.width - Theme.gap * (root.columns + 1)) / root.columns); // Reported out so Rust can place month headings: a heading belongs on a // cell that begins a row, and only the grid knows how wide a row is. changed columns => { root.columns-changed(root.columns); } property row-count: ceil(root.cells.length / max(1, columns)); // --- while a pinch is happening, and just after ----------------------- // // **A pinch does not end cleanly.** Lifting one finger of two leaves the // other one down, and Slint replays that survivor as a *fresh* `Pressed` // on whatever is under it — that is how it hands the pointer back to // ordinary handling. Under it is a cell. So the cell was selected, and // then lifting that last finger was a complete, well-formed click and the // photograph opened. Checking the finger id, which is what stops the // *second* finger's synthetic release from opening anything, cannot help // here: this press and this release are genuinely the same finger. // // Nothing in the event stream distinguishes that survivor from a real tap, // so the grid has to remember that a pinch just happened. The latch is // raised when the gesture starts and lowered a beat after it ends. // --- has this session been touched? ----------------------------------- // // **Hover is not a thing a finger does, but Slint reports it anyway.** // `has-hover` goes true for any pointer event carrying a position, a touch // press included, and false again on the `Exit` that follows the release. // So on a tablet the rating strip did appear — for exactly the length of a // tap. It flashed on under the finger, vanished as it lifted, and the tap // went through to the cell and opened the photograph. There was no way to // rate an unjudged frame from the grid at all. // // The strip therefore stops keying off hover as soon as there is evidence // that this is a touch session, and evidence is what `touch-finger-id` // is: zero for a mouse, never zero for a finger. Latched rather than // sampled per event, because the strip has to be on screen *before* the // finger arrives to be worth aiming at. // // One-way on purpose. A tablet with a mouse plugged in keeps the strips // once it has been touched, which is the harmless direction to be wrong // in — the alternative is chrome that comes and goes as the user changes // hands. // // Seeded by Rust as well as latched here, because the latch alone needs a // press to reach a cell and a quick flick never delivers one — the // Flickable claims the gesture before the delay it forwards after. On // Android touch is not evidence to be gathered, it is the platform, so it // starts true there and this is left to catch a touchscreen on the desktop. in-out property touched: false; property pinching: false; settle := Timer { interval: 350ms; running: false; triggered => { root.pinching = false; self.running = false; } } // How tall a screenful is, in rows. Measured rather than fixed: the same // constant is simultaneously too small on a maximised 4K window and // wasteful on a narrow one, and everything the loaded window does is // expressed as a multiple of this. property visible-rows: max(1, ceil(grid-area.height / (cell-size + Theme.gap))); /// Cells the viewport shows at once, counting the row the scroll position /// has cut in half. /// /// `visible-rows` is a ceiling on a grid whose first row starts at the top /// of the viewport, and the grid is only ever aligned like that at rest at /// the very top. Scrolled anywhere else, a part-row hangs off each end and /// the viewport touches one row more than that — the row this used to /// undercount is the bottom one, which is the row reported missing. property viewport-cells: root.columns * (root.visible-rows + 1); changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); } /// Rows the *whole library* occupies, which is what the scrollbar spans. property total-rows: ceil(root.total / max(1, columns)); background: Theme.ground; VerticalLayout { // --- header ------------------------------------------------------- Rectangle { height: 44px; background: Theme.surface; // Scrolls rather than overflowing, for the reason set out on the // filter row below: a layout given less width than its children // need does not shrink them, it overruns *and* reports the // oversized minimum upwards — which is how a header of buttons // ended up dictating how many columns of thumbnails the grid // thought it could draw. Seven action buttons and four readouts do // not fit across a tablet even in landscape, which is why the last // one was clipped by the screen edge. // // Sized to `min-width`, not `preferred-width`: the latter is the // row's *untruncated* footprint — every readout at its natural // text width — so the Flickable was always at least that wide and // the row never actually shrank, it just scrolled, with the // sidebar toggle and the "More" disclosure off past the right // edge on anything narrower than a tablet. `min-width` is what // the row needs once its `elide` + `horizontal-stretch: 1` // readouts (image count, selection count, scan status, the // window label below) are collapsed to an ellipsis, so a window // with room for that shrunk row now gets the row shrunk to fit // instead of scrolled — and the toggle and disclosure, which // don't shrink, stay on screen. Only a window too narrow even for // the shrunk row still scrolls. Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, header-row.min-width); header-row := HorizontalLayout { width: parent.viewport-width; height: parent.viewport-height; padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap; // The sidebar toggle, leading the header — the place every // interface with a collapsible sidebar puts one, and the only // place that stays put whichever way the panel is. IconButton { icon: "menu"; active: root.collections-visible; y: (parent.height - self.height) / 2; clicked => { root.toggle-collections(); } } Value { // The collection being shown takes the title when the grid // is scoped to one: that is what the user narrowed to, and // the folder is the less specific fact by then. text: root.scope-label != "" ? root.scope-label : (root.root-label != "" ? root.root-label : "Library"); overflow: elide; } // `elide` + `horizontal-stretch: 1`, like `window-label` below: // stretch is what actually lets a `Text` shrink under pressure // in Slint, not `elide` alone (see the comment on the header // row's Flickable) — without it these three readouts kept // their full natural width and were what pushed the sidebar // toggle and the "More" button past the right edge on a // narrow window, reachable only by knowing to flick-scroll. Caption { text: root.total > 0 ? root.total + " images" : ""; overflow: elide; horizontal-stretch: 1; } // The selection count, and how to act on it. Both appear only // when something is selected — an empty selection has nothing // to say and the buttons would be permanently greyed chrome. // A live selection is state, not a label — it is the thing the // buttons beside it act on — so it keeps `active` rather than // dropping to ink with the counts around it. Value { text: root.selected-count > 0 ? root.selected-count + " selected" : ""; modified: true; compact: true; font-weight: 600; overflow: elide; horizontal-stretch: 1; } Label { // The scan's own status while it runs; once it is done, // thumbnail progress takes the line over — that is the // work the user is actually waiting on by then. // No per-window preview count: it counted an arbitrary // batch, so "48 / 120" described the window's size rather // than anything the user cares about. Whole-library // indexing is the number worth showing, and the bar below // already says that the window itself is still filling. text: root.scanning ? root.scan-status : (root.sweeping ? "indexing " + root.sweep-done + " / " + root.sweep-total : root.scan-status); overflow: elide; horizontal-stretch: 1; } // Where in the library the visible window sits. A scrubbable // grid is disorienting without it. Caption { text: root.sweeping ? "indexing " + root.sweep-done + " / " + root.sweep-total : root.window-label; horizontal-alignment: right; horizontal-stretch: 1; overflow: elide; } // Expanded: the actions sit in the header as one row. There // is room, and a disclosure would be a click in front of a // button that was already visible. if root.expanded: HeaderActions { scope-label: root.scope-label; selected-count: root.selected-count; scanning: root.scanning; syncing: root.syncing; scope-pinned: root.scope-pinned; can-import: root.can-import; settings-armed: root.settings-armed; settings-summary: root.settings-summary; exporting: root.exporting; export-to-server: root.export-to-server; export-selection => { root.export-selection(); } cancel-export => { root.cancel-export(); } paste-settings-to-selection => { root.paste-settings-to-selection(); } remove-from-collection => { root.remove-from-collection(); } select-mode: root.select-mode; toggle-select-mode => { root.toggle-select-mode(); } add-to-collection => { // Reset every time it opens: see `filing-moves`. root.filing-moves = false; root.filing = true; // The disclosure has served its purpose and would // otherwise sit behind the sheet, so the row it belongs // to is still there when the sheet closes. root.actions-open = false; } add-keyword => { // Ask for the vocabulary before showing the sheet, so // it is answered against the selection as it stands now // rather than as it stood when the grid last loaded. root.keywords-opened(); root.keywording = true; root.actions-open = false; } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } rescan => { root.rescan(); } open-import => { root.open-import(); } open-settings => { root.open-settings(); } open-people => { root.open-people(); } } // Compact: one button in place of six. Labelled rather than a // bare glyph, because "⋯" alone in a header of words reads as // a truncation of the label beside it — which is exactly what // this change exists to stop happening. if !root.expanded: Button { text: root.actions-open ? "Close" : "More"; active: root.actions-open; y: (parent.height - self.height) / 2; clicked => { root.actions-open = !root.actions-open; } } } } } // The compact action row, disclosed by "More" above. // // A row beneath the header rather than a popup over the grid: a popup // needs a dismiss rule, an anchor and a decision about what happens // when the window resizes under it, and all this needs to be is the // six buttons somewhere they fit. It closes itself when the window // widens — see `actions-open`. if !root.expanded && root.actions-open: Rectangle { height: 44px; background: Theme.surface; // Scrolls, like the two rows above it. This is the row that exists // *because* the header did not fit, so it is the last place to // assume six buttons will. Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, actions-row.preferred-width); actions-row := HorizontalLayout { width: parent.viewport-width; height: parent.viewport-height; padding-left: Theme.gap; padding-right: Theme.gap; alignment: start; HeaderActions { centred: true; scope-label: root.scope-label; selected-count: root.selected-count; scanning: root.scanning; syncing: root.syncing; scope-pinned: root.scope-pinned; can-import: root.can-import; settings-armed: root.settings-armed; settings-summary: root.settings-summary; exporting: root.exporting; export-to-server: root.export-to-server; export-selection => { root.export-selection(); } cancel-export => { root.cancel-export(); } paste-settings-to-selection => { root.paste-settings-to-selection(); } remove-from-collection => { root.remove-from-collection(); } select-mode: root.select-mode; toggle-select-mode => { root.toggle-select-mode(); } add-to-collection => { // Reset every time it opens: see `filing-moves`. root.filing-moves = false; root.filing = true; // The disclosure has served its purpose and would // otherwise sit behind the sheet, so the row it belongs // to is still there when the sheet closes. root.actions-open = false; } add-keyword => { // Ask for the vocabulary before showing the sheet, so // it is answered against the selection as it stands now // rather than as it stood when the grid last loaded. root.keywords-opened(); root.keywording = true; root.actions-open = false; } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } rescan => { root.rescan(); } open-import => { root.open-import(); } open-settings => { root.open-settings(); } open-people => { root.open-people(); } } } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- rating filter ------------------------------------------------ // // Hidden while there is nothing to filter: an empty library offering // six rating buttons is chrome describing data that does not exist. if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged || root.filter-flag > 0 || root.filter-people.length > 0: Rectangle { height: 34px; background: Theme.surface; // --- the chips scroll rather than overflowing -------------- // // **A layout cannot be narrower than its children's minimums.** // Given less room than they need, Slint lays them out at their // minimums and lets the row run past the edge — and, worse, the // row reports that oversized minimum upwards. This row is a // sibling of the grid inside one VerticalLayout, so its minimum // became the *whole view's* minimum: `LibraryGrid` was laid out // wider than the window, the grid measured itself against that // inflated box, and the right-hand column was computed to fit in // space that was off the screen. Fourteen chips do not fit across // 768 logical pixels, so that is every tablet in portrait. // // A Flickable's own minimum is nothing — it is built to be smaller // than what it holds — so wrapping the row both stops it inflating // anything and makes the chips past the edge reachable instead of // merely absent. Flickable { width: 100%; height: 100%; // Horizontal only: there is one row of chips and it must not // drift vertically inside a 34px strip. viewport-height: self.height; viewport-width: max(self.width, chips.preferred-width); chips := HorizontalLayout { width: parent.viewport-width; height: parent.viewport-height; padding-left: Theme.gap; padding-right: Theme.gap; spacing: 4px; alignment: start; // First on the bar, ahead of "Show". Arriving here from the // People screen replaces the whole grid, and a chip explaining // that has to be the first thing read — a user who does not // find it is looking at a library that has apparently lost // most of its photographs. for p[i] in root.filter-people: FilterChip { icon: "cross"; label: p.name; active: true; y: (parent.height - self.height) / 2; clicked => { root.filter-person-cleared(p.id); } } // Only with two, because with one the modes are the same // filter and a toggle that changes nothing is a control that // teaches the user it does nothing. if root.filter-people.length > 1: FilterChip { label: root.filter-people-all ? "all of them" : "any of them"; active: root.filter-people-all; y: (parent.height - self.height) / 2; clicked => { root.filter-people-mode-toggled(); } } Caption { text: "Show"; vertical-alignment: center; } // Minimum-stars buttons. "All" first, then 1..5 — the same // left-to-right increasing order as the star strip itself, so // the two read as the same scale. FilterChip { label: "All"; active: root.filter-min-rating == 0 && !root.filter-unjudged && root.filter-flag == 0; y: (parent.height - self.height) / 2; clicked => { root.filter-min-rating-changed(0); root.filter-unjudged-toggled(false); root.filter-flag-changed(0); } } // Unrated, which is where a freshly scanned library lives in // its entirety — and what a resumed cull filters to. FilterChip { label: "Unrated"; count: root.rating-counts.length > 0 ? root.rating-counts[0] : -1; active: root.filter-unjudged; y: (parent.height - self.height) / 2; clicked => { root.filter-unjudged-toggled(!root.filter-unjudged); } } for n[i] in [1, 2, 3, 4, 5]: FilterChip { icon: "star"; label: n + "+"; count: root.rating-counts.length > n ? root.rating-counts[n] : -1; active: root.filter-min-rating == n; y: (parent.height - self.height) / 2; // Pressing the active one clears it, so the filter is its // own undo and "All" is not the only way back. clicked => { root.filter-min-rating-changed( root.filter-min-rating == n ? 0 : n); } } Rectangle { width: Theme.gap; } FilterChip { icon: "check"; label: "Picks"; active: root.filter-flag == 1; y: (parent.height - self.height) / 2; clicked => { root.filter-flag-changed(root.filter-flag == 1 ? 0 : 1); } } FilterChip { icon: "cross"; label: "Rejects"; active: root.filter-flag == 2; y: (parent.height - self.height) / 2; clicked => { root.filter-flag-changed(root.filter-flag == 2 ? 0 : 2); } } Rectangle { width: Theme.gap; } // Locally-stored originals. Always offered, not only when // offline: "what can I actually work on right now" is a fair // question on a slow connection too, and a control that // appears only in the failure case is one the user has to // discover at the worst moment. FilterChip { label: "On this device"; count: root.local-count; active: root.local-only; y: (parent.height - self.height) / 2; clicked => { root.toggle-local-only(); } } Rectangle { width: Theme.gap; } // Turn the range on, over the span the timeline is showing. // The histogram is already how you find a period, so this is // a starting point rather than the answer: the band it puts on // the axis is then dragged to the fortnight you meant, which // is the one way in that needs neither a keyboard nor a wheel. // Pressing again lifts the range. // // The histogram deliberately keeps drawing the full extent // while this is on, or there would be nothing left to widen // back out from. FilterChip { label: root.range-active ? "Date range ×" : "Limit to range"; active: root.range-active; y: (parent.height - self.height) / 2; clicked => { root.toggle-date-range(); } } Rectangle { horizontal-stretch: 1; } // What the filter is currently hiding. Without this a narrowed // grid and an empty library look identical, which is the // single most confusing state a filter can leave behind. Caption { text: (root.filter-min-rating > 0 || root.filter-unjudged || root.filter-flag > 0 || root.local-only) ? "filtered" : ""; emphasised: true; vertical-alignment: center; } } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- what is selected, and what to do with it -------------------- // // The count and these actions used to live only in the header row, // which scrolls sideways: on a tablet "12 selected" and everything // beside it sat past the right-hand edge, so a selection was something // you could make and then not see. A selection you cannot see is one // you act on by accident. // // Its own strip, like the date range's, appearing only while there is // a selection — so it costs nothing the rest of the time, and when it // is there it has the width of the window rather than the tail of a // row that did not fit. if root.selected-count > 0: Rectangle { height: 40px; background: Theme.surface; HorizontalLayout { width: 100%; height: 100%; padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap-sm; alignment: start; // State rather than a label: it is what the buttons beside it // act on. Value { text: root.selected-count + (root.selected-count == 1 ? " photograph selected" : " photographs selected"); modified: true; font-weight: 600; vertical-alignment: center; overflow: elide; } Rectangle { width: Theme.gap; } // The way out that is not "undo every tap". Distinct from // "Done", which leaves select mode entirely: clearing keeps the // mode, so the next selection can start straight away. Button { text: "Clear"; y: (parent.height - self.height) / 2; clicked => { root.clear-selection(); } } // Filing a selection into a collection that does not exist yet // took four steps: make a collection, find it, select the // photographs again, add them. One press instead, which is how // a selection is usually meant. Button { text: "New collection from selection"; primary: true; y: (parent.height - self.height) / 2; clicked => { root.collection-from-selection(); } } } } // --- the date range's ends --------------------------------------- // // Its own strip, a sibling of the chips rather than a child of them. // // Twice now this control has looked broken. First it narrowed to the // whole library, because it took its span from a timeline zoom that // is zero until someone zooms. Then the fields that fixed that went // into the chip row — which is 34px tall and scrolls sideways, so // they were both clipped and off past the right-hand edge. // // They are no longer the only way to state a range — the band on the // axis is, and on a phone it is the only usable one. These stay for // the two things dragging cannot do: name an exact day, and say what // the range currently is in words rather than as a position. // // A `Rectangle` stacks its children at the origin rather than laying // them out, which is why putting a second row inside the chips' one // drew it over them instead of under them. This is a row of the // header's `VerticalLayout`, so it gets a line of its own and the // width of the window. if root.range-active: Rectangle { height: 40px; background: Theme.surface; HorizontalLayout { width: 100%; height: 100%; // The same leading inset the chips above use, so the two rows // start on one vertical line rather than a few pixels apart. padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap-sm; // Fixed-width fields and a two-letter word: left-aligned, so // they sit under the chip that turned them on instead of // spreading across the window. alignment: start; Field { width: 108px; y: (parent.height - self.height) / 2; text: root.range-from; placeholder: "YYYY-MM-DD"; accepted(t) => { root.range-edited(t, root.range-to); } } Caption { text: "to"; vertical-alignment: center; } Field { width: 108px; y: (parent.height - self.height) / 2; text: root.range-to; placeholder: "YYYY-MM-DD"; accepted(t) => { root.range-edited(root.range-from, t); } } if root.range-invalid: Caption { text: "not a date"; warn: true; vertical-alignment: center; } } } // --- progress ----------------------------------------------------- // // The bar that used to sit here is now the shell's, drawn across the // top of every view from the activity register (see `activity.rs`). // Two reasons it moved. It only ever knew about the three things the // grid happens to report — a download running while the user was in // develop drew nothing anywhere — and a second bar here would now say // the same thing twice, one line apart. // // The grid keeps its *words*: "indexing 4000 / 17000" in the header // above says which work is running, which a bar cannot. // --- pinning ------------------------------------------------------ // // Its own line, with the count spelled out: the shell's bar says that // something is transferring, and this says how much of what. if root.pin-total > 0: Rectangle { height: 34px; background: Theme.surface; HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap; Caption { text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total; vertical-alignment: center; } ProgressBar { fraction: root.pin-done / max(1, root.pin-total); y: (parent.height - self.height) / 2; horizontal-stretch: 1; } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- offline ------------------------------------------------------ // // Above the scan error, and it suppresses it: when the server is // unreachable the scan failure is a *consequence*, and showing both // reports one problem twice while implying two. if root.offline: Rectangle { height: 34px; background: Theme.surface; HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap; Caption { text: "Offline — showing what is stored on this device" + (root.offline-since != "" ? " (" + root.offline-since + ")" : ""); warn: true; vertical-alignment: center; overflow: elide; } // The transport's own words. Usually specific enough to act on // — "connection refused" and "dns error" send the user to // different places — where a bare "offline" leaves them // guessing whether it is their wifi or the server. Caption { text: root.offline-reason; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } Button { text: "Retry"; y: (parent.height - self.height) / 2; clicked => { root.retry-connection(); } } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- error -------------------------------------------------------- if root.scan-error != "" && !root.offline: Rectangle { height: 34px; background: Theme.surface; Caption { text: root.scan-error; warn: true; horizontal-alignment: center; overflow: elide; } } // --- body: sidebar beside the grid -------------------------------- // // The capture-time axis is furniture, not a strip under the images: a // scroll position is relative, a date is absolute, and this is the // primary way of moving through the library. HorizontalLayout { vertical-stretch: 1; // Always present, never conditional on having bars. // // Creating it on `timeline.length > 0` made the sidebar's 96px // appear the moment the first dates were recorded, which narrowed // the grid — changing `columns` and `viewport-cells`, both of which call // back into Rust to reload the window. The first sweep flush // therefore landed a reload storm on top of the initial thumbnail // batch. Reserving the column costs 96px on an undated library and // keeps the grid's width stable while dates arrive. // // An empty `bars` already renders as bare furniture: the `for` // loops produce nothing and the gestures index an empty array only // under a pointer that has no bar to land on. Timeline { bars: root.timeline; range-label: root.timeline-label; current-start: root.current-bucket; current-fraction: root.current-bucket-fraction; anchored: root.timeline-anchored; range-from: root.range-from-fraction; range-to: root.range-to-fraction; range-changed(a, b) => { root.timeline-range-changed(a, b); } scrub-to(f) => { root.scrub-fraction(f); } pinch(r) => { root.timeline-pinch(r); } pan(d) => { root.timeline-pan(d); } zoom(d) => { root.timeline-zoom(d); } } grid-area := VerticalLayout { horizontal-stretch: 1; // --- empty state -------------------------------------------------- // // "Still scanning" and "scanned, found nothing" are different answers. // Conflating them is how a working scan looks broken. if root.total == 0: EmptyState { headline: root.scanning ? "Scanning…" : "No images found"; detail: root.scanning ? root.scan-status : "Check the library folder and which formats are ticked."; } // --- keyboard judgement (FR-CULL-4) ------------------------------- // // `0`–`5` set stars, `P`/`X` pick and reject, `U` clears the flag. // These are the keys every culling tool uses, and muscle memory // built elsewhere is worth more here than any improvement. // // Zero-height rather than wrapping the grid: a FocusScope in this // layout would claim a slot and push the grid up, and one *around* // the Flickable competes with it for the arrow keys. This holds // focus and forwards nothing else. // // Applies to the **selection**, not to a cell under the pointer — // that is what makes rating forty frames a single keystroke, and it // matches what the header's count says is selected. judge-keys := FocusScope { height: 0px; // The grid is the primary surface of this screen, so it takes // focus on show rather than waiting for a click. Without this // the first keystroke of a culling session is swallowed. init => { self.focus(); } key-pressed(event) => { // TRACES: FR-UI-2 | FR-UI-4 // Back and Escape close what is open here, innermost // first, before the shell above gets to read them as // "leave the library". On Android that is the system Back // button, and a sheet it walked straight past would leave // the user out of the grid with their selection gone. if (event.text == Key.Back || event.text == Key.Escape) { if (root.keywording) { root.keywording = false; return accept; } if (root.filing) { root.filing = false; return accept; } if (root.select-mode) { root.toggle-select-mode(); return accept; } return reject; } if (event.text == "0") { root.judged(0, -1); return accept; } if (event.text == "1") { root.judged(1, -1); return accept; } if (event.text == "2") { root.judged(2, -1); return accept; } if (event.text == "3") { root.judged(3, -1); return accept; } if (event.text == "4") { root.judged(4, -1); return accept; } if (event.text == "5") { root.judged(5, -1); return accept; } // Case-insensitive: caps lock during a long cull must not // silently stop the keys working. if (event.text == "p" || event.text == "P") { root.judged(-1, 1); return accept; } if (event.text == "x" || event.text == "X") { root.judged(-1, 2); return accept; } if (event.text == "u" || event.text == "U") { root.judged(-1, 0); return accept; } // Delete moves the selection to the trash folder on the // server. Unlike every other key here it is not metadata — // it relocates files — but it is also the key every file // manager binds to exactly this, and the operation is // reversible from the trash view. if (event.text == Key.Delete || event.text == Key.Backspace) { root.trash-selection(); return accept; } // `F2` renames the collection the grid is scoped to — the // rename key everywhere else, and the reason it is bound // here is that this scope is what holds focus in library // mode. Rust ignores it when nothing is scoped. if (event.text == Key.F2) { root.rename-scope(); return accept; } // --- walking the grid --------------------------------- // // The keys that make a cull possible without the mouse: // arrows move the cursor, shift extends the selection from // the anchor, `Return` opens what the cursor is on. The // judgement keys above act on the selection, so walking // with the arrows and rating as you go is one hand's work. // // Every one of these is `accept`ed. The `Flickable` scrolls // on arrow keys of its own accord, and letting it would // move the view out from under a cursor that had not // moved — the grid is scrolled *to* the cursor instead, // and only when the cursor leaves the viewport. // // The vertical steps are expressed in columns and rows, // which only the grid knows: how far "down" is depends on // how wide the window happens to be. if (event.text == Key.LeftArrow) { root.move-cursor(-1, event.modifiers.shift); return accept; } if (event.text == Key.RightArrow) { root.move-cursor(1, event.modifiers.shift); return accept; } if (event.text == Key.UpArrow) { root.move-cursor(-root.columns, event.modifiers.shift); return accept; } if (event.text == Key.DownArrow) { root.move-cursor(root.columns, event.modifiers.shift); return accept; } if (event.text == Key.PageUp) { root.move-cursor(-root.columns * root.visible-rows, event.modifiers.shift); return accept; } if (event.text == Key.PageDown) { root.move-cursor(root.columns * root.visible-rows, event.modifiers.shift); return accept; } // A step longer than the library, clamped at the far end. // `total` is what the grid was told the library holds, so // this stays honest as it grows. if (event.text == Key.Home) { root.move-cursor(-root.total, event.modifiers.shift); return accept; } if (event.text == Key.End) { root.move-cursor(root.total, event.modifiers.shift); return accept; } if (event.text == Key.Return) { root.open-cursor(); return accept; } return reject; } } // --- the grid ----------------------------------------------------- // // `interactive` stays true: `DragArea` and `Flickable` arbitrate // properly, so dragging a cell drags the cell and dragging the // background still flicks the grid. (This is the part a hand-rolled // TouchArea gesture could not do — see the drag comments above.) if root.total > 0: grid-scroll := Flickable { // Ctrl+wheel resizes the cells; a plain wheel is declined and // falls through to the Flickable's own scrolling. Two jobs on // one gesture, distinguished by the modifier — the convention // every image browser uses. // // Declared *first* so it sits beneath the cells in z-order: // their own touch areas still take clicks and drags, and only // a wheel event nothing else claimed reaches this. zoom-catcher := TouchArea { width: 100%; height: parent.viewport-height; scroll-event(e) => { if (e.modifiers.control) { root.zoom-cells(e.delta-y > 0 ? 1 : -1); return accept; } return reject; } } // Two-finger pinch, for tablet: the same gesture the timeline // uses, applied to cell size rather than to time. // // Sized to the **viewport**, like `zoom-catcher` above and for // the same reason. `100%` inside a Flickable is the Flickable's // own height, and the pinch is delivered to whatever lies under // the midpoint of the two fingers — so a handler one screenful // tall sat at the top of a viewport thousands of rows long and // was under the fingers only while the grid had not been // scrolled. Anywhere else the gesture found nothing to land on. grid-pinch := ScaleRotateGestureHandler { width: 100%; height: parent.viewport-height; property last-scale: 1.0; started => { self.last-scale = 1.0; root.pinching = true; settle.running = false; // The finger that opened this gesture landed on a cell // and selected it. It was reaching for the grid, not // for that photograph. root.pinch-started(); } // Continuous, not stepped. Thresholding this into ±1 zoom // steps meant the grid lurched 25% at a time and sat still // in between, which is the whole of "pinching is not // smooth". The ratio since the last update is what tracks // the fingers; where the drawn cell lands is still a whole // number of columns, because the columns divide the width. updated => { root.pinch-cells(self.scale / max(0.01, self.last-scale)); self.last-scale = self.scale; } // The latch outlives the gesture — see `pinching`. ended => { self.last-scale = 1.0; settle.running = true; } cancelled => { self.last-scale = 1.0; settle.running = true; } } // Follow a requested position. Without this a scrub moves the // *loaded window* while the viewport stays where it was, so // the cells are drawn thousands of rows away and the grid // looks empty until the user scrolls to find them. function seek() { self.viewport-y = -min( max(0px, self.viewport-height - self.height), floor(root.scroll-to / max(1, root.columns)) * (root.cell-size + Theme.gap)); } property 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 pitch: root.cell-size + Theme.gap; property cursor-row: floor(root.cursor / max(1, root.columns)); changed cursor-row => { self.reveal(); } function reveal() { if (root.cursor < 0) { return; } let top = Theme.gap + self.cursor-row * self.pitch; let shown = -self.viewport-y; let bottom = max(0px, self.viewport-height - self.height); if (top < shown) { self.viewport-y = -min(bottom, top); } else if (top + self.pitch > shown + self.height) { self.viewport-y = -min(bottom, top + self.pitch - self.height); } } // Also on creation, which is what returning from the develop // view needs. `show-library` gates an `if`, so the grid is built // anew and `token` is *initialised* to the already-bumped value // rather than changing to it — no `changed` handler fires, and // without this the restored position would be dropped and the // view would sit at the top. init => { self.seek(); } // Sized to the **whole library**, not the loaded window. The // scrollbar has to represent 23,971 images or there is no way to // reach image 20,000 — dragging it must be a real address, and the // window is swapped underneath to match. viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap; // TRACES: FR-CAT-7 // Stay inside the content when the content shrinks. // // Deleting photographs makes the library shorter, and the // viewport is sized to the *whole* library — so a view that was // scrolled near the end is suddenly scrolled past it. Slint // does not pull a Flickable back on its own, so the grid went // blank: the cells were still there, above a viewport looking // at empty space below them. // // Worse than blank, it also *moved*. Cells are drawn at // `(i + offset) / columns`, and a delete re-clamps `offset` // downward to keep the window full — so the same `viewport-y` // now points at a different part of the library, and the grid // appeared to jump somewhere arbitrary. Re-seeking below is the // other half of this; this half stops the blank. changed viewport-height => { if (-self.viewport-y > max(0px, self.viewport-height - self.height)) { self.viewport-y = -max(0px, self.viewport-height - self.height); } } // Report the first fully-scrolled-past row so Rust can move the // window. Derived rather than eventful: Slint has no scroll // callback, and a `changed` handler on a derived integer fires only // when the row actually changes rather than on every pixel. property 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; } } // The end a shift-click measures from. An inner ring rather // than a colour of its own: it has to be legible against a // thumbnail of any brightness and must not read as a second // kind of selection, which a hue would. Rectangle { visible: cell.anchor; x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; background: transparent; border-width: 1px; border-color: Theme.selected-ring; border-radius: Theme.radius-sm; } // Selection only. The drag is the enclosing `DragArea`'s // business, and Slint keeps a click distinct from a drag for // us — which is exactly the arbitration the hand-rolled version // had to fake with a travel threshold. cell-touch := TouchArea { mouse-cursor: pointer; // Selected on *press*, not on release: the drag that may // follow reads the selection to build its payload, and by // release the pointer is over the sidebar. // // In selection mode the press is reported as though ctrl // were held. That is not a shortcut: toggling one cell // while keeping the rest *is* what ctrl-click means, and // giving touch its own policy would be a second copy of // the rules in `collections_ui::apply_press` to keep in // step with the first. // --- which finger, and whether it finished -------- // // A pinch begins as an ordinary press. When the second // finger lands Slint closes the first one's gesture by // synthesising a `Released` at its position — that is // how a Flickable is made to let go of a scroll it had // already claimed. A TouchArea cannot tell that release // from a real one and fires `clicked`, so every attempt // to pinch-zoom the grid opened whichever photograph // the first finger happened to be resting on. // // The finger id is what separates them: the synthetic // release carries the id of the finger that *arrived*, // never the one that pressed. A mouse reports 0 for // both, so the desktop path is unchanged. // // `clicked` fires before the `up` that follows it, so // it can only raise a flag — the decision to open has // to wait for the event that names the finger. property down-finger: -1; property click-pending: false; pointer-event(ev) => { if (ev.kind == PointerEventKind.down) { self.down-finger = ev.touch-finger-id; self.click-pending = false; // A finger, not a pointer — see `touched`. if (ev.touch-finger-id != 0) { root.touched = true; } // Not while the grid is being pinched, nor in // the moment after: this is the finger left // over from the gesture, handed back as a new // press. See `pinching`. if (!root.pinching) { root.cell-pressed( i, ev.modifiers.control || root.select-mode, ev.modifiers.shift, ); } } if (ev.kind == PointerEventKind.up) { // A *plain* click opens the image; a modified // one is purely a selection gesture and must // not navigate away from the grid the user is // building a selection in. The modifier state // is not carried here, so the press above // records it and Rust decides — `cell-clicked` // is only honoured when the press was // unmodified. if (self.click-pending && !root.pinching && ev.touch-finger-id == self.down-finger) { root.cell-clicked(i); } self.click-pending = false; root.cell-press-ended(); } // `cancel` is the important ending: the Flickable // takes the pointer as soon as the finger travels, // so without this a scroll that began on a cell // would come to rest as a long press and select it. if (ev.kind == PointerEventKind.cancel) { self.click-pending = false; root.cell-press-ended(); } } clicked => { self.click-pending = true; } // The far end of a range, in selection mode. Slint // delivers `clicked` for the first tap as well, which // is why the toggling is idempotent-by-union in Rust // rather than this file trying to swallow one of them. double-clicked => { root.cell-double-clicked(i); } // --- the rating strip, INSIDE the cell's hit area --- // // **A child of `cell-touch`, not a sibling of it.** Two // things have to be true at once here, and only this // nesting gets both. // // A star must take the click without it also reaching // `cell-clicked`, or every rating throws the user into // develop. Children are hit-tested before the element // they sit in, and a child that accepts ends the walk // before the TouchArea's own handler runs — so the star // wins, and it wins for the same reason a later sibling // used to. // // And the strip must not vanish as the pointer arrives // at it. As a *sibling* it did: hover is tracked per // TouchArea, Slint sends `Exit` to whatever drops out of // the hit path, and the strip taking the pointer dropped // `cell-touch` out of it. `has-hover` went false, which // took `show-empty` with it, which hid the very stars // the pointer was travelling towards — on an unrated // cell they disappeared, the click landed on the cell // behind them, and the image opened. That is the whole // of the "stars vanish when I click one" report. // // An *ancestor* stays in the path: it keeps its place on // the item stack, gets no `Exit`, and during a grab it is // handed only the filter and never the event. So hover // holds for as long as the pointer is anywhere in the // cell, stars included. // // It sits over the foot of the thumbnail rather than // below it: the caption row is spoken for by the // filename, and a third row would cost thumbnail height // on every cell to show something that is usually empty. StarStrip { x: (parent.width - self.width) / 2; // Clear of the caption, which is the cell's last row. y: parent.height - self.height - 26px; rating: cell.rating; // Empty stars appear once the pointer is over the // cell, so there is something to aim at without // filling the grid with chrome. That trade only // works where there is a pointer to hover with: on // touch the strip stands open, because a control // that appears under the finger is a control that // appears too late to aim at. See `touched`. show-empty: cell-touch.has-hover || root.touched; can-trash: !root.viewing-trash; rate(n) => { root.cell-rated(i, n); } trash() => { root.cell-trashed(i); } } } } } } } } } // --- the filing sheet (FR-CAT-7, FR-UI-4) ------------------------------- // // "Put these in…", for the times a drag is not available: one finger on a // scrolling grid, or a selection made across a scrub where the sidebar has // long since been closed to give the photographs the width. // // Last in the file, so it draws over the grid — and outside the // VerticalLayout above, so appearing does not reflow the header and the // cells underneath it. if root.filing: Rectangle { background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the // card's own controls sit above it. TouchArea { clicked => { root.filing = false; } } Rectangle { width: min(420px, parent.width - 2 * Theme.gap-lg); // Tall enough for the list, but never taller than the window: a // library with forty collections must still leave the buttons on // screen, which is what the Flickable inside is for. height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: root.selected-count == 1 ? "File 1 photograph in…" : "File " + root.selected-count + " photographs in…"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; wrap: word-wrap; } // Filing is a *copy*: a photograph can be in as many // collections as it belongs in, which is what a join table // means and what the drag has always done. Moving is the // exception and has to be asked for, because it is the one // that takes something away. if root.scope-label != "": Button { text: root.filing-moves ? "Moving out of " + root.scope-label : "Also keep in " + root.scope-label; active: root.filing-moves; clicked => { root.filing-moves = !root.filing-moves; } } Rectangle { height: 1px; background: Theme.rule; } Flickable { vertical-stretch: 1; // A floor, so the list is not squeezed out of existence by // the buttons around it on a short window. min-height: 120px; viewport-height: root.collections.length * (Theme.touch-target + 2px); for row[i] in root.collections: Rectangle { y: i * (Theme.touch-target + 2px); width: parent.width; // A full touch target per row, where the sidebar's // equivalent is 26px. The sidebar is a place to look; // this is a place to hit once, with a thumb, holding a // selection that took a minute to build (FR-UI-3). height: Theme.touch-target; background: row-touch.pressed ? Theme.pressed : (row-touch.has-hover ? Theme.hover : transparent); border-radius: Theme.radius-sm; // A saved filter's membership is its selector, so it // cannot be filed into — the same refusal the sidebar // makes on a drag, made here before the tap rather // than after it. opacity: row.smart ? 0.4 : 1.0; HorizontalLayout { padding-left: Theme.gap-sm + row.depth * Theme.indent; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Icon { name: row.smart ? "collection-smart" : "collection"; ink: Theme.ink-faint; size: 14px; y: (parent.height - self.height) / 2; } Text { text: row.name; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } Text { text: row.smart ? "computed" : (row.deep-count > 0 ? row.deep-count + "" : ""); color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } row-touch := TouchArea { enabled: !row.smart; clicked => { root.file-in-collection(row.id, root.filing-moves); root.filing = false; } } } if root.collections.length == 0: Text { text: "No collections yet. Make one with + in the sidebar."; color: Theme.ink-faint; font-size: Theme.text-sm; wrap: word-wrap; width: parent.width; } } Rectangle { height: 1px; background: Theme.rule; } Button { text: "Cancel"; clicked => { root.filing = false; } } } } } // --- the keywording sheet (FR-CAT-5, FR-CAT-6) -------------------------- // // "These are of…". Deliberately the same card, scrim and dismissal as the // filing sheet above: a user who has filed a selection already knows how // this works, and a second idiom for the same gesture would be a second // thing to learn for no gain. // // It stays open after each word, where the filing sheet closes. Filing is // one choice; keywording is usually several — "puffin", "Látrabjarg", // "2026" — and a sheet that shut after each one would have to be reopened, // and the selection re-confirmed, three times over. if root.keywording: Rectangle { background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the // card's own controls sit above it. TouchArea { clicked => { root.keywording = false; } } Rectangle { width: min(420px, parent.width - 2 * Theme.gap-lg); height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } kw-sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: root.selected-count == 1 ? "Keywords for 1 photograph" : "Keywords for " + root.selected-count + " photographs"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; wrap: word-wrap; } // Typing a word applies it, whether or not it already exists. // One field for both, because "is this keyword new?" is a // question about the catalog and not about what the user meant, // and Rust can answer it without being asked. // // The field clears itself on accept so the next word can be // typed straight after — keywording a shoot is a run of them. new-keyword := Field { placeholder: "Type a keyword and press return"; accepted(text) => { root.assign-keyword(text); self.text = ""; } } Rectangle { height: 1px; background: Theme.rule; } Flickable { vertical-stretch: 1; // A floor, so the list is not squeezed out of existence by // the field and the button around it on a short window. min-height: 120px; viewport-height: root.keywords.length * (Theme.touch-target + 2px); for word[i] in root.keywords: Rectangle { y: i * (Theme.touch-target + 2px); width: parent.width; // A full touch target per row, for the same reason the // filing sheet uses one: this is a place to hit once, // with a thumb, holding a selection that took a minute // to build (FR-UI-3). height: Theme.touch-target; background: kw-touch.pressed ? Theme.pressed : (kw-touch.has-hover ? Theme.hover : transparent); border-radius: Theme.radius-sm; HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; // Tick, dash, or nothing — the three states of // `coverage`, drawn as three different marks rather // than as two. A half-applied keyword shown as // applied is a lie about photographs the user // cannot see from here. Rectangle { width: 16px; y: (parent.height - self.height) / 2; height: 16px; border-radius: Theme.radius-sm; border-width: 1px; border-color: word.coverage == 0 ? Theme.rule : Theme.active; background: word.coverage == 2 ? Theme.active : transparent; // The dash for "some of them". A bar rather // than a tick, because a tick at half strength // reads as a rendering artefact. if word.coverage == 1: Rectangle { width: 8px; height: 2px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.active; } if word.coverage == 2: Icon { name: "check"; ink: Theme.surface; size: 12px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } } Text { text: word.name; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } // "3 of 12" only where it says something the mark // does not. For a word the whole selection carries, // or none of it, the mark has already said it and // the number would be noise on every row. Text { text: word.coverage == 1 ? word.selected-count + " of " + root.selected-count : (word.image-count > 0 ? word.image-count + "" : ""); color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } // One target for both directions. A word the selection // fully carries comes off; anything else goes on — so a // partly-applied keyword is completed rather than // removed, which is what a user tapping a dash means // nine times in ten, and the tenth is one more tap // away. kw-touch := TouchArea { clicked => { if (word.coverage == 2) { root.unassign-keyword(word.name); } else { root.assign-keyword(word.name); } } } } if root.keywords.length == 0: Text { text: "No keywords yet. Type one above to make the first."; color: Theme.ink-faint; font-size: Theme.text-sm; wrap: word-wrap; width: parent.width; } } Rectangle { height: 1px; background: Theme.rule; } Button { text: "Done"; clicked => { root.keywording = false; } } } } } }