// The library grid: what a scanned library looks like. // // Cells come from Rust as a windowed model, never the whole catalog — a 17k // image library must not become 17k live elements (FR-CAT-4). // // A cell with no thumbnail yet shows why rather than an empty box. On a remote // library a thumbnail is a network round trip, so "nothing there", "not // fetched yet", and "no preview in this file" are three different states and // must not look identical (FR-NC-6c). import { Theme } from "theme.slint"; import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint"; // The filing sheet lists the same rows the sidebar draws, from the same model: // two lists of collections that could disagree about what exists is one list // too many. import { CollectionRow } from "collections.slint"; import { GestureSheet, GestureRow } from "gestures.slint"; // 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; /// Whether the marker has a position to report. False only before the first /// axis has been built — Rust seeds it from wherever the grid sits, so a /// launch draws the marker where the photographs on screen are rather than /// leaving it dimmed at mid-track waiting for a scroll. in property 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 on the filter bar, in either of the two roles it plays there. /// /// The chips for who the grid is *currently* narrowed to, and the roster in the /// tray the user picks from, are the same thing drawn twice — same id, same /// wording — so they are one struct. `faces` and `picked` are the tray's half /// and are simply not read by the narrowed-to chips. export struct PersonChip { id: int, name: string, /// How many faces the library holds of them. `-1` where it was not asked /// for, which `FilterChip` draws as no number at all rather than as zero. faces: int, /// Already one of the people the grid is narrowed to. picked: bool, } export struct LibraryCell { name: string, // Non-empty on the first cell of a new month, e.g. "August 2026". The grid // is ordered by capture time, so these are the only place the date is // legible without consulting the sidebar — a wall of thumbnails otherwise // gives no sense of when you are. period-heading: string, // Empty until a fetch lands. `has-thumb` disambiguates, because Slint // cannot test an image against null. thumbnail: image, has-thumb: bool, // A fetch that completed with no usable preview. Distinct from pending. unavailable: bool, // Part of the current selection. Selection is what a drag carries, so this // has to be per-cell state rather than a single "current" index. selected: bool, // How many collections this image belongs to. An image can be in many at // once, and without a cue the grid gives no hint that a photograph has // already been filed — the user re-files it, or hunts for where it went. collection-count: int, // This cell is one of the images currently being dragged. It reads as // *lifted out*: desaturated and shrunk in place, so the grid shows where // the photographs came from while the cursor shows them in full colour. lifted: bool, // Stars, 0..5. Zero is *unrated* — a state of its own, not a low score, // and what "filter to unjudged" selects (FR-CULL-4). rating: int, // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a // four-star frame is a normal thing to do mid-cull. flag: int, // Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it // belongs to none, which is most of a library. A burst that is collapsed // draws only its representative, so on that cell this is the count of what // is hidden behind it — the reason it is shown at all. burst-count: int, // Whether the group is currently open. Drawn differently rather than // hidden: a burst the user has expanded is the one thing on screen that // needs a way back, and a control that disappears once used is a control // nobody finds twice. burst-expanded: bool, // Whether this is the frame the group folds up to — the earliest of them // until the user says otherwise. Read only while the group is open: folded, // the one cell on screen is the representative by construction, and a mark // saying so would be telling the user what they can already see. burst-representative: bool, } // The photo roll: the grid's loaded window along the foot of the develop view. // // # Why this exists // // Develop opens *one* photograph. `index` and `total` are pinned to "1 of 1" // on the way in, because the grid hands over a path and nothing else, so the // only way to reach the next frame was to go back to the library, find where // you were, and tap again. That is fine once and intolerable through a set of // forty — which is precisely the situation the develop view exists for. // // # The gesture // // A swipe up from the bottom edge brings it out, a swipe down puts it away: // the sheet gesture, already in the hands of anyone who has used a phone. // A gesture with no visible counterpart is a feature only its author knows // about (FR-UI-4), so a handle is drawn at the edge and is a button in its own // right — which is also what gives a pointer, with no swipe to make, a way in. // // The handler wraps the strip rather than sitting over or under it. That is // what `SwipeGestureHandler` is built for: it delays a press the way a // Flickable does, forwards it to the children if no swipe develops, and claims // it once one does. A tap therefore reaches the thumbnail and a drag does not. // // It covers only the band along the bottom, never the whole canvas — above // that band a drag belongs to the photograph, for panning and for the crop — // and while the roll is away the band narrows to the handle's own `reach`, // because a gesture handler consumes every press that lands in it. The note // on `swipe` below says what that cost before it did. export component PhotoRoll inherits Rectangle { in property <[LibraryCell]> cells; /// Which row of the loaded window is open, so it can be marked. `-1` when /// the open photograph is not in the window at all, which is the honest /// answer after a scrub — the roll shows where you are, and sometimes the /// answer is "not here". in property current: -1; in-out property open: false; /// Centre the open photograph the next time the strip settles, instead of /// merely bringing it into view. /// /// **A one-shot request, cleared here rather than by Rust**, because the /// only thing that knows the request has been honoured is the code that /// honours it. Rust raises it when a photograph is opened *from the grid* — /// the start of a develop session — and from then on every pick along the /// roll uses `reveal()`, which moves as little as will show the mark. That /// is the difference the user asked for: arriving at a photograph should /// show what is either side of it, and stepping along from there should not /// keep yanking the strip back to centre. /// /// It has to be a flag the strip consumes rather than something recomputed /// on creation, because the strip is created more often than a session /// begins: leaving develop for Settings and coming back rebuilds it, and /// re-centring then would undo a roll the user had scrolled by hand. in-out property centre-request: false; /// A thumbnail was chosen. The row within the loaded window, matching what /// a cell click reports. callback pick(int); /// Both from the theme rather than from here, because the canvas /// positions its floating controls against the band these two describe. property strip-height: Theme.roll-strip; /// The band of canvas left grabbable when the roll is away. A thumb's /// worth, and no more: it is taken off the bottom of the photograph. property reach: Theme.roll-reach; property thumb: 92px; property pad: 6px; background: transparent; swipe := SwipeGestureHandler { width: 100%; height: root.strip-height + root.reach; // **The whole handler slides, and the strip sits still inside it.** // // A gesture handler is an input surface, not only a box to hang the // strip on: while a press lands inside it that press is *taken* — it // is delayed, then offered to the handler's own children and to // nothing else, so no element behind it is ever asked. A band the full // height of the strip is right while the strip is out and is a dead // zone over the foot of the photograph while it is away, which is // where the crop's bottom handles and the canvas's floating controls // live. // // So the band goes where the roll goes. Closed, only `reach` of it is // on screen — the handle, and the thumb's width of canvas that starts // the swipe; the rest hangs below the window where nothing can press // it. Open, it covers the strip, which is what lets a swipe down // anywhere across the thumbnails put the roll away. // // This is also why the animation moved here from the strip: the strip // is now fixed at `reach` within the handler, so the handler's `y` is // the only thing left that travels. y: root.open ? parent.height - self.height : parent.height - root.reach; animate y { duration: 180ms; easing: ease-out; } handle-swipe-up: !root.open; handle-swipe-down: root.open; // Direction decides, not distance: the handler has already applied its // own threshold by the time this fires, and re-testing the travel here // would mean a swipe that qualified as a swipe still did nothing. swiped => { root.open = self.current-position.y < self.pressed-position.y; } // --- the strip ------------------------------------------------ // // Slid out of view rather than removed. An `if` would have it appear // fully formed at the bottom of the screen instead of arriving from // the edge, and would leave nothing for the animation to act on. // What travels is the handler around it; see the note on `swipe`. strip := Rectangle { width: 100%; height: root.strip-height; // Below the handle and no lower: the handler above carries both // of them in and out. y: root.reach; background: Theme.surface; clip: true; Rectangle { width: 100%; height: 1px; background: Theme.rule; } roll := Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, root.cells.length * (root.thumb + root.pad) + root.pad); // Bring the open photograph into view — on opening, and when // the roll itself moves the selection along. Without it a pick // near the end of the window scrolls back to the start on the // next reveal, and the mark the strip exists to show is off // the edge of it. function reveal() { if (root.current < 0) { return; } let left = root.pad + root.current * (root.thumb + root.pad); let shown = -self.viewport-x; let right = max(0px, self.viewport-width - self.width); if (left < shown) { self.viewport-x = -min(right, left); } else if (left + root.thumb > shown + self.width) { self.viewport-x = -min(right, left + root.thumb - self.width); } } // Put the open photograph in the middle of the strip, with as // much of the roll either side of it as there is. // // Clamped to the ends rather than centred unconditionally: the // third photograph of a window cannot be centred without // scrolling empty space in on the left, and a strip that starts // with a gap reads as broken rather than as centred. function centre() { if (root.current < 0) { return; } let left = root.pad + root.current * (root.thumb + root.pad); let right = max(0px, self.viewport-width - self.width); let want = left + root.thumb / 2 - self.width / 2; self.viewport-x = -min(right, max(0px, want)); } // Which of the two the strip does, and the only place the // request is cleared. // // **Nothing happens before the strip has a width.** Centring // divides by it, and `init` runs before layout — so honouring // the request there would scroll to an arithmetic answer based // on a width of zero *and* consume the flag, leaving the roll // wrong with nothing left to correct it. Deferring instead is // safe because the width arriving is itself a change, and // `changed width` below settles again once it has. function settle() { if (self.width <= 0px) { return; } if (root.centre-request) { root.centre-request = false; self.centre(); } else { self.reveal(); } } property mark: root.current; changed mark => { self.settle(); } property shown: root.open; changed shown => { if (self.shown) { self.settle(); } } // On creation, and again when the strip is first given a // width. None of the four is redundant. // // The strip is rebuilt on every entry to develop, so `mark` and // `shown` are *initialised* rather than changed and neither // handler above fires — which is precisely the moment a session // begins and the request is waiting to be honoured. A roll left // open from the previous session would otherwise slide in // showing whatever it showed last. // // `init` runs before layout, though, so it usually finds no // width and defers; `changed width` is what actually honours the // request a frame later. Both are kept because the order is not // guaranteed, and a settle that has nothing to do is a no-op. init => { self.settle(); } changed width => { self.settle(); } for cell[i] in root.cells: Rectangle { x: root.pad + i * (root.thumb + root.pad); y: root.pad; width: root.thumb; height: parent.height - 2 * root.pad; background: Theme.ground; border-radius: Theme.radius; // The one that is open, marked the way the grid marks a // selection so the two read as the same idea. border-width: i == root.current ? 2px : 0px; border-color: Theme.selected-ring; clip: true; Image { width: 100%; height: 100%; source: cell.thumbnail; image-fit: contain; visible: cell.has-thumb; } TouchArea { mouse-cursor: pointer; clicked => { root.pick(i); } } } } } // --- the handle ----------------------------------------------- // // Rides on the strip's top edge, so it is in the same place relative to // the roll whether it is in or out, and it is the affordance that stops // the swipe being folklore. Rectangle { width: 84px; height: root.reach; x: (parent.width - self.width) / 2; y: strip.y - self.height; background: Theme.surface; border-radius: Theme.radius; Rectangle { width: 32px; height: 3px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.ink-dim; border-radius: 2px; } TouchArea { mouse-cursor: pointer; clicked => { root.open = !root.open; } } } } } // TRACES: NFR-A11Y-3 // A row of five stars, readable at a glance and clickable to set a rating. // // **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids // status by hue alone, and the palette is achromatic anyway — so a set star is // a solid star at `active` and an unset one is an outline at `ink-faint`. The // two differ in both shape and luminance, which survives greyscale and low // vision alike. // // **Unrated draws nothing until hovered.** A grid of 120 cells each showing // five empty stars is a wall of chrome competing with the photographs; a // freshly scanned library would look like a spreadsheet. The strip appears on // hover, so an unjudged cell is quiet and a judged one is legible from across // the room. export component StarStrip inherits Rectangle { in property 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); } } } } } // TRACES: NFR-A11Y-3 // The pick/reject mark. // // A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars: // a tick and a cross are distinguishable without hue, and a reject reads as a // reject in greyscale. A reject also dims its whole cell, which is the cue // that carries at grid scale — the mark is confirmation, not the primary // signal. export component FlagMark inherits Rectangle { in property flag: 0; width: 16px; height: 16px; border-radius: 8px; visible: root.flag > 0; background: Theme.surface; opacity: 0.92; Icon { name: root.flag == 2 ? "cross" : "check"; // Reject earns the one hue in the palette: it is the destructive end // of the axis and the thing a user must not mistake for a pick. ink: root.flag == 2 ? Theme.warn-ink : Theme.active; size: 9px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } } // The header's action buttons, in one place so they can be drawn in two. // // A tablet in portrait is 768 logical pixels wide, and the header wants a // title, a status line and these buttons. Laid out in one row they all shrink // to their minimum and elide — "Change library" arrives as "Change li…" — and // the row still overflows its own width. Slint's HorizontalLayout has no wrap // and no overflow, so the row has to be told what to drop. // // Extracted rather than duplicated because the alternative is these buttons // written twice with their visibility rules and callbacks copied, and the // copy that gets forgotten is the one behind the disclosure nobody opens // while testing. // // **Nothing in here depends on the selection.** Everything a selection can be // done *to* lives on the bar at the foot of the grid, which is also where the // selection is reported. Six buttons used to appear and disappear from the // middle of this row as photographs were picked, shunting `Sync`, `Settings` // and everything beside them several hundred pixels sideways at the exact // moment a hand was already travelling toward one — and splitting "what can I // do with these?" across two bars at opposite ends of the window. A row that // only changes when the *library* changes can be aimed at from memory. // // Nor does anything in here refer to *one collection*. "Keep offline" and // "Change library" are in the collections panel, under the tree whose // selection decides what the first of them acts on and which the second // replaces wholesale. component HeaderActions inherits HorizontalLayout { /// TRACES: FR-UI-2 | FR-UI-4 /// Whether taps are selecting rather than opening. The non-gesture half of /// touch multi-selection: the long press is the quick way in, and this is /// the way that can be *found*. in property select-mode: false; in property scanning: false; in property syncing: 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 toggle-select-mode(); callback sync-now(); /// TRACES: FR-CAT-10 /// Whether this platform can reach a card at all (`dr_plat::imports_supported`). /// /// Hidden rather than disabled, unlike the buttons that come and go with a /// scan: those are unavailable *now* and will be available in a moment, /// where this one never will be on this device. A permanently disabled /// control teaches the reader that the row lies. in property can-import: false; callback rescan(); callback open-import(); callback open-settings(); callback open-people(); /// TRACES: FR-UI-4 /// Open the gesture reference. callback open-gestures(); spacing: Theme.gap; // TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7 // The way *into* selecting, and the only selection control left in this // row — everything a selection can be done *to* is on the bar at the foot // of the grid, beside the count that says what it would act on. // // It exists at all because the desktop's way in — ctrl-click, shift-click — // has no touch equivalent, and the drag that files a photograph in a // collection needs a *selection* before it can carry more than one. On a // tablet the long press does this faster; a gesture with no visible // counterpart is a feature only its author knows about (FR-UI-4). // // "Done" rather than "Selecting": the label should say what pressing it // does, and the inverted fill already says which state we are in. Button { text: root.select-mode ? "Done" : "Select"; active: root.select-mode; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.toggle-select-mode(); } } Button { // Shares the finished index so a second device inherits it rather // than repeating hours of range fetches. text: root.syncing ? "Syncing…" : "Sync"; enabled: !root.syncing && !root.scanning; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.sync-now(); } } if !root.scanning: Button { text: "Rescan"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.rescan(); } } // TRACES: FR-CAT-10 // Beside Rescan rather than beside Settings: both put photographs into the // library, where Settings is about the application. Hidden during a scan // for the same reason Rescan is — an import writes files the running scan // would then half-see, and the two disagreeing about what is in a folder // is a worse outcome than waiting. if !root.scanning && root.can-import: Button { text: "Import"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-import(); } } // A library action rather than a setting, so it sits with the others and // ahead of Settings, which the comment below keeps last. Button { text: "Identity"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-people(); } } // TRACES: FR-UI-4 // What the gestures are, for the ones that have no button of their own. // Beside Settings because it is about the application rather than about // the photographs, and unconditional for the same reason Settings is: a // reference you can only reach in some states is one you look for in the // state where you needed it and do not find. Button { text: "Gestures"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-gestures(); } } // Last in the row, and unconditional. Nothing before it moves with the // selection any more, so this is a fixed address rather than a control // that drifts as photographs are picked. Button { text: "Settings"; y: root.centred ? (root.row-height - self.height) / 2 : 0; clicked => { root.open-settings(); } } } export component LibraryGrid inherits Rectangle { /// TRACES: FR-UI-1 /// The layout class, from the window width rather than the device. /// /// Compact moves the header's actions behind a disclosure; see /// [`HeaderActions`]. Defaults to expanded so a caller that forgets to /// pass it gets the desktop layout rather than a hidden toolbar. in property 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; /// The catalog on this device is being opened and checked before the scan /// starts. Distinct from `scanning` for the reason the empty state below /// gives: they are two different waits and only one of them is the network. in property opening: 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; // --- the header's one status line ------------------------------------- // // Four readouts used to sit in the header beside the title: the image // count, the selection count, the scan's status and where in the library // the visible window sits. Each carried `horizontal-stretch: 1` — which is // what actually lets a `Text` shrink in Slint — so between them they took // about a third of a 768px header and left the buttons to scroll off the // end of it. // // The selection count is gone from here entirely: it belongs beside the // buttons that act on it, which is the bar at the foot of the grid. // // The other three are one subject — what this grid is showing and what is // being done to it — so they are one sentence with separators, and one // stretch between them. Two of them even said the same words while a sweep // ran: "indexing 300 / 12 480" appeared as the status *and* as the window // position. // // Assembled in two steps rather than one so the separators can be dropped // along with the parts they follow. A library with nothing scanned and // nothing loaded should read as empty, not as " · · ". property status-phrase: root.scanning ? root.scan-status : (root.sweeping ? "indexing " + root.sweep-done + " / " + root.sweep-total : root.scan-status); property status-line: (root.total > 0 ? root.total + " images" : "") + (root.status-phrase != "" ? (root.total > 0 ? " · " : "") + root.status-phrase : "") + (root.window-label != "" ? ((root.total > 0 || root.status-phrase != "") ? " · " : "") + root.window-label : ""); callback cell-clicked(int); /// A star was clicked on a cell: row, and the rating 0..5. callback cell-rated(int, int); /// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back /// up. Which of the two is decided in Rust, from what the catalog says the /// group is currently doing, so the mark cannot get out of step with the /// query that actually hides the frames. callback burst-toggled(int); /// A frame of an open burst was named as the one it folds to (FR-CULL-5), /// by row. Nothing here decides *which* frame deserves it — the catalog's /// default is the earliest, a fact about the clock rather than a judgement /// about the photograph, and this is the photographer overruling it. callback burst-representative-chosen(int); /// Whether the grid is currently listing the trash rather than the /// library. Suppresses the per-cell trash target, which would be inert /// there — `plan_trash` skips an already-trashed image — and offering a /// control that does nothing is worse than offering none. in property 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(); /// 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(); /// TRACES: FR-UI-4 /// The gesture reference's rows, from Rust — which reads them from the /// generated table. See gestures.slint for why they cannot be written here. in property <[GestureRow]> gestures; /// Whether the reference is up. Local, like `naming` and `filing`: nothing /// in Rust needs to know a sheet is open. property helping: false; // --- selection and drag --- // // A click selects; ctrl-click adds to the selection; shift-click extends a // range. Dragging a selected cell carries the whole selection, which is // what makes "put these forty photographs in that collection" one gesture. // // The drag itself is Slint's own `DragArea`, not a hand-rolled gesture. The // first attempt here tracked presses and travel through a `TouchArea` and // failed for a reason worth recording: an interactive `Flickable` claims any // drag that begins inside it for scrolling, cancelling the child // TouchArea's press, so the gesture could never leave the grid. `DragArea` // is arbitrated properly against the Flickable, keeps the pointer capture // across component boundaries, and draws its own cursor overlay — which is // also why there is no badge position to compute here any more. /// Modifier state at press time, so Rust can decide replace / add / extend /// without the .slint file encoding the selection policy. callback cell-pressed(int, bool, bool); /// TRACES: FR-UI-2 | FR-UI-4 /// The press on a cell ended — lifted, or taken away by the Flickable when /// the finger travelled. Cancels the long-press timer that would otherwise /// turn a scroll into a selection. Rust owns that timer: Slint has no /// long-press gesture, and a hand-rolled one here would need a `Timer` per /// visible cell. callback cell-press-ended(); /// TRACES: FR-UI-2 | FR-UI-4 /// Whether a tap selects rather than opens. /// /// Held in Rust beside the selection it modifies, so the long press and the /// header's button are two doors into one state rather than two states that /// can disagree. In this mode a plain tap toggles a cell — exactly what /// ctrl-click does with a pointer, which is why the press below passes it /// as the ctrl flag rather than as a third selection policy. // GESTURE: Start selecting several photographs // where: Library grid // touch: Press and hold a photograph, or press Select in the header // pointer: Ctrl-click, or press Select in the header // why: Touch has no ctrl, so without a mode there is no way to // select a second photograph — the first tap would open it. // The hold is the fast way in and the button is the one that // can be found. // // GESTURE: Add or remove one photograph // where: Library grid // touch: While selecting, tap it // pointer: Ctrl-click it // why: While selecting, a tap never opens. That is the whole point // of the mode: one meaning per gesture at a time. Press Done to // get tap-to-open back. // // GESTURE: Leave selecting // where: Library grid // touch: Press Done in the header // pointer: Press Done in the header // keys: Escape in property select-mode: false; callback toggle-select-mode(); /// TRACES: FR-CAT-7 | FR-UI-4 /// The photograph a press has held long enough to pick up, or `-1`. /// /// **The visible half of a gesture that was folklore.** A finger on a cell /// is ambiguous — it may be starting a scroll or taking hold of a /// photograph — and Slint resolves that by giving the `Flickable` the first /// half-second: any press that travels more than a few pixels vertically /// inside it becomes a scroll, and the drag never begins. Only a fast /// sideways flick, or waiting the half-second out, ever picked a /// photograph up, and nothing on the screen said so. The user's account of /// it was that dragging "sometimes works". /// /// So the wait is given a mark. The same hold that turns on selection mode /// sets this, a ring opens outward around the cell, and from that moment /// the drag is the only thing the finger can be doing — the grid below is /// no longer `interactive`, so there is no scroll left to lose to. The cue /// can only ever arrive *after* the ambiguity has passed, which is the /// honest direction: once the ring is open, dragging works. /// /// A row of the loaded window, like every other row here. It is cleared /// when the press ends and when a drag finishes, and the grid cannot /// scroll while it is set, so it cannot outlive the window it indexes. // GESTURE: Pick a photograph up to drag it // where: Library grid // touch: Press and hold it until a ring opens around it, then drag // pointer: Drag it // why: A finger on a photograph might be starting a scroll, and for // the first half-second the grid assumes it is. Holding says // otherwise, and the ring is the grid saying it heard — from // there the drag cannot be lost to a scroll. A mouse never // waits: the cursor is precise enough that a sideways drag is // unambiguous from the first pixel. in-out property held-row: -1; /// TRACES: FR-CAT-5 // --- reordering a manual collection (FR-CAT-7) -------------------------- // // `collection_members.position` and `Sort::CollectionPosition` have existed // in the catalog since collections did, and nothing above it ever wrote or // read them: the grid ordered everything by capture time, always. This is // the gesture that makes the column mean something. // // Only where there is a manual order to change — a single manual collection // with no children. A set interleaves two children's unrelated positions // and a smart collection has no member rows at all, so both fall back to // capture time and refuse the drop rather than pretending. /// Whether a drop on a cell should move photographs within the collection /// being shown. Set by Rust from the scope, because what a scope *is* is a /// catalog question. in property reorderable: false; /// Move the selection so it sits beside the photograph at this row of the /// loaded window — before it, or after it where the drop landed on the /// cell's trailing half. The trailing half is what makes the last position /// reachable at all; without it there is no cell to drop "before". callback reorder-to(int, bool); /// Drop the selection without leaving select mode. callback clear-selection(); /// TRACES: FR-CAT-5 | FR-UI-4 /// Take everything the grid is currently showing — the whole library, or /// the whole of whatever it is scoped and filtered to. Answered from the /// catalog, not from the loaded window, for the reason `cell-pressed`'s /// shift argument is: what is on screen is a fraction of what is meant. callback select-all(); /// TRACES: FR-UI-2 | FR-UI-4 /// Whether the next cell tap should take everything from the last cell /// tapped to it. /// /// **Why this exists.** Touch has had a range gesture for as long as /// selection mode has — double-tap the far end — and it was a gesture only /// its author could find. It is invisible, it is unreliable on a grid that /// scrolls under the second tap, and it extends from the anchor *before* /// the two taps moved it, a rule subtle enough to need two paragraphs of /// Rust to explain to itself. /// /// This is the same operation made visible: a button that arms it, a strip /// that says what the next tap will do, and a way out. It also does the one /// thing a drag-to-select sweep could not — the user may scroll as far as /// they like between the two taps, and the run is resolved by the catalog /// rather than by what happens to be on screen. The ranges that hurt on a /// tablet are longer than a screenful, which is exactly where a sweep runs /// out. /// /// Local to this file, and one-shot: the next press consumes it. Rust needs /// no state for it, because it arrives as `cell-pressed`'s shift argument /// and lands in `apply_press` as the shift-click it already knows how to /// apply. // GESTURE: Select a range // where: Library grid // touch: While selecting, press "Select to…", then tap the last // photograph of the run // pointer: Shift-click the last photograph of the run // why: This replaced a double tap, which had no visible state and // could take forty photographs by accident. The run is resolved // by the catalog rather than by what is on screen, so the grid // can scroll between the two taps — the ranges that hurt on a // tablet are longer than a screenful, which is exactly where a // finger sweep runs out. property ranging: false; // A range armed against nothing has no anchor to extend from, and the strip // that says it is armed is only drawn while there is a selection — so // "Done" in the header, which drops the selection from outside that strip, // would leave the mode armed and invisible, and the next ordinary tap would // take a run the user never asked for. changed selected-count => { if (root.selected-count == 0) { root.ranging = false; } } /// TRACES: FR-CAT-5 /// Make a new collection, under the given name, holding exactly what is /// selected. The name arrives from the sheet below rather than being /// invented by Rust and corrected afterwards — see `naming`. callback collection-from-selection(string); /// **What arms the drag, not what it carries.** /// /// `DragArea` refuses to begin a drag while its `data` is empty, and it /// asks on every pointer event — including the first one, long before /// anything is being dragged. A binding that calls a callback is evaluated /// once and cached, because there is nothing for Slint to invalidate it on, /// so whatever this answers the first time a finger touches a cell is what /// that cell's `DragArea` believes for the rest of its life. /// /// It is therefore *not* the payload the drop reads, whatever it looks /// like: what travels is `dragging` in `collections_ui`, recorded by /// `drag-started` and read back by `dropped-on`. See the Rust side for why /// this has to answer something non-empty unconditionally. pure callback drag-payload() -> data-transfer; /// What travels under the cursor: the dragged thumbnail, or a fanned stack /// of them where several are being carried. Composited in Rust, because /// Slint accepts one bitmap here and cannot draw a pile of images into it. in property drag-image; /// A drag began on this cell. /// /// This, not `drag-payload`, is where what the drag carries is decided: it /// fires with the selection as it stands at the moment the drag starts, and /// lets Rust promote an unselected cell into the selection first. callback drag-started(int); /// The drag ended — dropped or cancelled. Clears the transient UI state. callback drag-finished(); // --- rating filter (FR-CAT-6, FR-CULL-4) --- // // The filter narrows what the grid *queries*, not what it draws: on a // remote library, drawing then hiding would still have fetched every // thumbnail, which is the cost FR-NC-3 exists to avoid. /// Minimum stars to show. 0 shows everything. in property 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; /// Everyone the library knows, for the tray. Filled on demand — see the /// tray itself for why it is not simply pushed alongside the chips. in property <[PersonChip]> people; /// Asked for when the tray opens, so a roster is never built for a bar /// nobody has opened and never goes stale in one that is. callback people-listed(); /// Add or remove one person from the filter, keeping the rest. /// /// The gesture the whole tray exists for: `filter-person-cleared` can only /// take somebody *out*, so before this the only way to narrow to two people /// at once was to visit the People screen twice. callback filter-person-toggled(int); /// Whether the people tray is open. /// /// Private to the view, like every other disclosure here: nothing in Rust /// needs to know, and a strip whose open state round-tripped through a /// callback would flicker on every press. property people-tray: false; /// 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) ----------------------------------------------- // // How far the download has got. Progress is shown because pinning a trip // is gigabytes of transfer — a button that appeared to do nothing for // twenty minutes would read as broken. // // Only the progress. Asking for the pin, and saying whether the scoped // collection already has one, belong to the collections panel — the tree // is what decides which collection is meant. This bar reports on the // transfer wherever it was started, including from a row this grid is not // scoped to, which is why it is not gated on the scope. in property pin-done: 0; in property pin-total: 0; /// 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; callback paste-settings-to-selection(); /// TRACES: FR-DEV-6 callback open-presets(); // TRACES: FR-EXP-7 // Exporting the selection. The grid owns neither the settings that decide // where the files go nor the worker that writes them — it reports what is // selected and asks, exactly as it does for a paste. in property 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(); /// Show every collection the selection is filed in, with a way out of each. /// The button above can only ever name the scoped collection; this is how a /// photograph leaves one the grid is not currently showing — and the only /// way to find out which collections it is in at all, since the cell badges /// a count and nothing else. callback open-membership(); // --- filing the selection (FR-CAT-7, FR-UI-4) --------------------------- // // The drag onto the sidebar is the fast way to file photographs, and it is // a pointer gesture: a one-finger drag beginning inside the grid belongs to // the Flickable that scrolls it, which is the arbitration described at the // top of `collections_ui.rs` working exactly as it should. So touch needs a // way in that is not a drag, and that is this sheet. // // The rows are the sidebar's own model, passed through rather than queried // again: two lists of collections is one list too many, and the one that // goes stale is always the one nobody is looking at. in property <[CollectionRow]> collections; /// Whether the sheet is up. Local, because it is a disclosure rather than a /// preference — nothing outside this file needs to know it is open, and /// what closes it is choosing a collection or dismissing it. property 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; // --- naming a new collection (FR-CAT-5, FR-CAT-7) ----------------------- // // "New collection from selection" used to create the collection under a // placeholder name and then open the rename field in the sidebar tree. // // On a tablet the sidebar is not on screen. It is instantiated all the // same — `app.slint` collapses it to zero width and `visible: false` // rather than using an `if`, because an `if` there is a layout loop Slint // panics on — so the rename field was created, its `init` took focus, and // Android raised the on-screen keyboard for a box nobody could see. Nothing // else on the screen is focusable, so the keyboard had nowhere to go: it // stayed, the name could not be typed, and the collection was already // written under the name the user did not want. // // Asked here instead, before anything is written. A sheet dismissed leaves // no collection behind, which the create-then-rename order could not // promise. /// Whether the naming sheet is up. Local, like `filing` and `keywording`. property naming: false; // Cell geometry. Columns are derived from the available width so the grid // reflows with the window rather than fixing a count (FR-UI-1). // Zoomable, so the grid serves both jobs: fewer, larger images for // judging one, and more, smaller ones for finding one. Driven from Rust so // the value survives a scope change and the thumbnail class can follow it. /// The size class the user chose. What is actually drawn is `cell-size` /// below, which is this capped to what the grid can hold. in property 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; // A floor, for the same reason the status line beside it // has one — and it is named here because the two of them // compete. An eliding `Text` has a minimum of nothing, so // once the buttons had claimed their own minimums the // title was the cheapest thing in the row to give away and // it went to a bare "…": the window's own name, gone, // while the byte counts beside it stayed. Whatever else // this row drops, it may not stop saying what is being // looked at. min-width: 90px; } // Everything the grid has to say about itself, in one line: // how many images, what is running over them, and where in the // library the visible window sits. See `status-line` for why // the four readouts that used to be here are one. // // `elide` + `horizontal-stretch: 1`: stretch is what actually // lets a `Text` shrink under pressure in Slint, not `elide` // alone (see the comment on the header row's Flickable) — // without it this readout keeps its full natural width and is // what pushes the sidebar toggle and the "More" button past // the right edge on a narrow window, reachable only by knowing // to flick-scroll. Caption { text: root.status-line; overflow: elide; horizontal-stretch: 1; // Deliberately without a `min-width`, unlike the title. // // This row cannot always hold everything: after the // sidebar takes its 232, a 1100pt window leaves the header // about 868, and the buttons alone want most of that // before a single character is drawn. Something has to // give, and the order matters — a floor here bought a // readable status by pushing `Settings` off the right // edge, reachable only by knowing to flick-scroll, which // is the exact fault this row's Flickable comment warns // about. // // So this is what gives. It is the most redundant thing in // the header — the sidebar states the library's count and // the filter chips state it again — and it grows back the // moment there is room. } // Expanded: the actions sit in the header as one row. There // is room, and a disclosure would be a click in front of a // button that was already visible. if root.expanded: HeaderActions { scanning: root.scanning; syncing: root.syncing; can-import: root.can-import; select-mode: root.select-mode; toggle-select-mode => { root.toggle-select-mode(); } sync-now => { root.sync-now(); } rescan => { root.rescan(); } open-import => { root.open-import(); } open-settings => { root.open-settings(); } open-people => { root.open-people(); } open-gestures => { root.helping = true; } } // Compact: one button in place of the row. Labelled rather // than a bare glyph, because "⋯" alone in a header of words // reads as a truncation of the label beside it — which is // exactly what this change exists to stop happening. if !root.expanded: Button { text: root.actions-open ? "Close" : "More"; active: root.actions-open; y: (parent.height - self.height) / 2; clicked => { root.actions-open = !root.actions-open; } } } } } // The compact action row, disclosed by "More" above. // // A row beneath the header rather than a popup over the grid: a popup // needs a dismiss rule, an anchor and a decision about what happens // when the window resizes under it, and all this needs to be is the // library's buttons somewhere they fit. It closes itself when the // window widens — see `actions-open`. if !root.expanded && root.actions-open: Rectangle { height: 44px; background: Theme.surface; // Scrolls, like the two rows above it. This is the row that exists // *because* the header did not fit, so it is the last place to // assume its buttons will. Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, actions-row.preferred-width); actions-row := HorizontalLayout { width: parent.viewport-width; height: parent.viewport-height; padding-left: Theme.gap; padding-right: Theme.gap; alignment: start; HeaderActions { centred: true; scanning: root.scanning; syncing: root.syncing; can-import: root.can-import; select-mode: root.select-mode; toggle-select-mode => { root.toggle-select-mode(); } sync-now => { root.sync-now(); } rescan => { root.rescan(); } open-import => { root.open-import(); } open-settings => { root.open-settings(); } open-people => { root.open-people(); } open-gestures => { root.helping = true; } } } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- rating filter ------------------------------------------------ // // Hidden while there is nothing to filter: an empty library offering // six rating buttons is chrome describing data that does not exist. if root.total > 0 || root.filter-min-rating > 0 || root.filter-unjudged || root.filter-flag > 0 || root.filter-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(); } } // The way in to the people tray, and the reason it exists. // // Narrowing to *two* people at once was already possible and // effectively unreachable: the only control that could add a // second one lived on the People screen, behind selecting them // there, and it only appeared once the grid was already // narrowed to somebody. So a user who wanted "photographs with // both of them" had to guess a two-screen round trip. A filter // belongs on the filter bar; this chip is the whole feature's // front door and the tray below is where both terms and the // any/all choice actually are. // GESTURE: Find photographs with two people in them // where: Library grid // touch: Open the People chip on the filter bar, tap each // name, then switch the chip beside them to "all of // them" // pointer: Open the People chip on the filter bar, click each // name, then switch the chip beside them to "all of // them" // why: "Any of them" is a union and "all of them" is an // intersection. The tray is where both terms and the // choice between them live, because a filter belongs // on the filter bar. FilterChip { icon: root.people-tray ? "chevron-down" : "chevron-right"; label: "People"; // The number narrowed to, not the size of the roster: it // says what the filter is doing, which is what every other // count on this bar says. count: root.filter-people.length > 0 ? root.filter-people.length : -1; active: root.people-tray; y: (parent.height - self.height) / 2; clicked => { root.people-tray = !root.people-tray; // Asked for on open rather than kept in step, so the // roster is current — indexing and regrouping change // who exists, and a list cached at startup would be // stale for exactly the user who has just been naming // people. if (root.people-tray) { root.people-listed(); } } } Caption { text: "Show"; vertical-alignment: center; } // Minimum-stars buttons. "All" first, then 1..5 — the same // left-to-right increasing order as the star strip itself, so // the two read as the same scale. FilterChip { label: "All"; active: root.filter-min-rating == 0 && !root.filter-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; } } // --- the people tray ---------------------------------------------- // // A second strip under the filter bar rather than a popup, for the // reason the develop column's film picker gives: this view already // scrolls as one, so an inline strip is taller content and not a second // overlay with its own dismiss gesture to lose a drag to. // // Horizontally scrolling, exactly like the bar above it and for the // same hard reason — **a layout cannot be narrower than its children's // minimums**, and a library with forty people would otherwise report a // minimum width of forty chips and inflate the whole view. See the bar // above for the full account of that fault. if root.people-tray: Rectangle { height: 38px; background: Theme.surface; Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, people-row.preferred-width); people-row := HorizontalLayout { width: parent.viewport-width; height: parent.viewport-height; padding-left: Theme.gap; padding-right: Theme.gap; spacing: 4px; alignment: start; Caption { // Says what a *pair* of chips will mean before either // is pressed, which is the thing the old design never // said anywhere. text: root.filter-people-all ? "In every one:" : "In the picture:"; vertical-alignment: center; } // The roster. Ordered most-photographed-first with the // named ahead of the rest, so the people a user actually // intersects are the ones under the thumb without // scrolling. for p[i] in root.people: FilterChip { icon: p.picked ? "check" : ""; label: p.name; count: p.faces; active: p.picked; y: (parent.height - self.height) / 2; clicked => { root.filter-person-toggled(p.id); } } // Not an error and not empty chrome: face indexing is an // opt-in overnight pass, so "nobody yet" is the ordinary // state of a library nobody has run it on, and it should // say where the pass lives. if root.people.length == 0: Caption { text: "Nobody indexed yet — find faces on the People screen."; vertical-alignment: center; } } } } // --- the date range's ends --------------------------------------- // // Its own strip, a sibling of the chips rather than a child of them. // // Twice now this control has looked broken. First it narrowed to the // whole library, because it took its span from a timeline zoom that // is zero until someone zooms. Then the fields that fixed that went // into the chip row — which is 34px tall and scrolls sideways, so // they were both clipped and off past the right-hand edge. // // They are no longer the only way to state a range — the band on the // axis is, and on a phone it is the only usable one. These stay for // the two things dragging cannot do: name an exact day, and say what // the range currently is in words rather than as a position. // // A `Rectangle` stacks its children at the origin rather than laying // them out, which is why putting a second row inside the chips' one // drew it over them instead of under them. This is a row of the // header's `VerticalLayout`, so it gets a line of its own and the // width of the window. if root.range-active: Rectangle { height: 40px; background: Theme.surface; HorizontalLayout { width: 100%; height: 100%; // The same leading inset the chips above use, so the two rows // start on one vertical line rather than a few pixels apart. padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap-sm; // Fixed-width fields and a two-letter word: left-aligned, so // they sit under the chip that turned them on instead of // spreading across the window. alignment: start; Field { width: 108px; y: (parent.height - self.height) / 2; text: root.range-from; placeholder: "YYYY-MM-DD"; accepted(t) => { root.range-edited(t, root.range-to); } } Caption { text: "to"; vertical-alignment: center; } Field { width: 108px; y: (parent.height - self.height) / 2; text: root.range-to; placeholder: "YYYY-MM-DD"; accepted(t) => { root.range-edited(root.range-from, t); } } if root.range-invalid: Caption { text: "not a date"; warn: true; vertical-alignment: center; } } } // --- progress ----------------------------------------------------- // // The bar that used to sit here is now the shell's, drawn across the // top of every view from the activity register (see `activity.rs`). // Two reasons it moved. It only ever knew about the three things the // grid happens to report — a download running while the user was in // develop drew nothing anywhere — and a second bar here would now say // the same thing twice, one line apart. // // The grid keeps its *words*: "indexing 4000 / 17000" in the header // above says which work is running, which a bar cannot. // --- pinning ------------------------------------------------------ // // Its own line, with the count spelled out: the shell's bar says that // something is transferring, and this says how much of what. if root.pin-total > 0: Rectangle { height: 34px; background: Theme.surface; HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap; Caption { text: "Downloading for offline — " + root.pin-done + " of " + root.pin-total; vertical-alignment: center; } ProgressBar { fraction: root.pin-done / max(1, root.pin-total); y: (parent.height - self.height) / 2; horizontal-stretch: 1; } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- offline ------------------------------------------------------ // // Above the scan error, and it suppresses it: when the server is // unreachable the scan failure is a *consequence*, and showing both // reports one problem twice while implying two. if root.offline: Rectangle { height: 34px; background: Theme.surface; HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap; Caption { text: "Offline — showing what is stored on this device" + (root.offline-since != "" ? " (" + root.offline-since + ")" : ""); warn: true; vertical-alignment: center; overflow: elide; } // The transport's own words. Usually specific enough to act on // — "connection refused" and "dns error" send the user to // different places — where a bare "offline" leaves them // guessing whether it is their wifi or the server. Caption { text: root.offline-reason; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } Button { text: "Retry"; y: (parent.height - self.height) / 2; clicked => { root.retry-connection(); } } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } // --- error -------------------------------------------------------- if root.scan-error != "" && !root.offline: Rectangle { height: 34px; background: Theme.surface; Caption { text: root.scan-error; warn: true; horizontal-alignment: center; overflow: elide; } } // --- body: sidebar beside the grid -------------------------------- // // The capture-time axis is furniture, not a strip under the images: a // scroll position is relative, a date is absolute, and this is the // primary way of moving through the library. HorizontalLayout { vertical-stretch: 1; // Always present, never conditional on having bars. // // Creating it on `timeline.length > 0` made the sidebar's 96px // appear the moment the first dates were recorded, which narrowed // the grid — changing `columns` and `viewport-cells`, both of which call // back into Rust to reload the window. The first sweep flush // therefore landed a reload storm on top of the initial thumbnail // batch. Reserving the column costs 96px on an undated library and // keeps the grid's width stable while dates arrive. // // An empty `bars` already renders as bare furniture: the `for` // loops produce nothing and the gestures index an empty array only // under a pointer that has no bar to land on. Timeline { bars: root.timeline; range-label: root.timeline-label; current-start: root.current-bucket; current-fraction: root.current-bucket-fraction; anchored: root.timeline-anchored; range-from: root.range-from-fraction; range-to: root.range-to-fraction; range-changed(a, b) => { root.timeline-range-changed(a, b); } scrub-to(f) => { root.scrub-fraction(f); } pinch(r) => { root.timeline-pinch(r); } pan(d) => { root.timeline-pan(d); } zoom(d) => { root.timeline-zoom(d); } } grid-area := VerticalLayout { horizontal-stretch: 1; // --- empty state -------------------------------------------------- // // "Still scanning" and "scanned, found nothing" are different answers. // Conflating them is how a working scan looks broken. // // And "still opening" is a third, which is the state a launch is in // for as long as it takes to read and check the catalog on this // device. It used to be spent before the window had painted at all, // where it read as a frozen application — on Android, as an ANR. // Now it is a worker, so it needs a sentence: a grid saying // "Scanning…" while nothing is on the network is the same kind of // lie the two answers below were separated to avoid. if root.total == 0: EmptyState { headline: root.opening ? "Opening the library…" : (root.scanning ? "Scanning…" : "No images found"); detail: (root.opening || 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 ----------------------------------------------------- // // **Scrolls until a photograph has been picked up, and not after.** // // `DragArea` and `Flickable` do arbitrate, but not evenly: the // Flickable claims any press that travels more than eight pixels // along its own axis within half a second of landing, and it holds // that claim until the finger lifts. That is right for the ordinary // case — a finger that moves is almost always scrolling — and it is // why dragging the background still flicks the grid. // // It is wrong once the user has said otherwise. `held-row` is that // saying: a press that has stayed put long enough to be a pick-up, // marked on the cell so the user can see it. From there this stops // being interactive and the drag has nothing left to lose to. // // Today the hold outlasts the Flickable's window anyway, so this // mostly makes an accident into a guarantee — the arbitration stops // depending on two constants in different crates staying in the // order they happen to be in. The wheel is unaffected: `interactive` // does not gate it. if root.total > 0: grid-scroll := Flickable { interactive: root.held-row < 0; // Ctrl+wheel resizes the cells; a plain wheel is declined and // falls through to the Flickable's own scrolling. Two jobs on // one gesture, distinguished by the modifier — the convention // every image browser uses. // // Declared *first* so it sits beneath the cells in z-order: // their own touch areas still take clicks and drags, and only // a wheel event nothing else claimed reaches this. zoom-catcher := TouchArea { width: 100%; height: parent.viewport-height; scroll-event(e) => { if (e.modifiers.control) { root.zoom-cells(e.delta-y > 0 ? 1 : -1); return accept; } return reject; } } // Two-finger pinch, for tablet: the same gesture the timeline // uses, applied to cell size rather than to time. // // Sized to the **viewport**, like `zoom-catcher` above and for // the same reason. `100%` inside a Flickable is the Flickable's // own height, and the pinch is delivered to whatever lies under // the midpoint of the two fingers — so a handler one screenful // tall sat at the top of a viewport thousands of rows long and // was under the fingers only while the grid had not been // scrolled. Anywhere else the gesture found nothing to land on. // GESTURE: Resize the thumbnails // where: Library grid // touch: Pinch the grid with two fingers // pointer: Ctrl and the scroll wheel // why: There is no wheel on a tablet, so without the // pinch the cell size could only be changed by a // control a finger cannot reach. grid-pinch := ScaleRotateGestureHandler { width: 100%; height: parent.viewport-height; property 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; } // Nothing to reveal *into* before layout has run, and this // is now called from `init` as well as on a cursor move — // where a height of zero would make every row look off // screen and scroll the grid to the cursor when `seek` had // just put it somewhere deliberate. if (self.height <= 0px) { return; } self.revealed = true; let top = Theme.gap + self.cursor-row * self.pitch; let shown = -self.viewport-y; let bottom = max(0px, self.viewport-height - self.height); if (top < shown) { self.viewport-y = -min(bottom, top); } else if (top + self.pitch > shown + self.height) { self.viewport-y = -min(bottom, top + self.pitch - self.height); } } /// Whether a reveal has run against real geometry yet. /// /// The safety net for the deferral above, and deliberately a /// latch rather than a plain `changed height` handler: the first /// height a rebuilt grid is given must bring the cursor into /// view, and every height *after* that is a window being /// resized, where dragging a corner should not keep hauling the /// viewport back to a cursor the user is not looking at. property revealed: false; changed height => { if (!self.revealed) { self.reveal(); } } // Also on creation, which is what returning from the develop // view needs. The grid is gated on an `if`, so it is built anew // and `token` is *initialised* to the already-bumped value // rather than changing to it — no `changed` handler fires, and // without this the restored position would be dropped and the // view would sit at the top. // // `reveal()` after `seek()`, and the order is the point: seek // puts the viewport where the grid was left, reveal then moves // it as little as will bring the cursor's row into view. Coming // back from develop the cursor is on the photograph that was // open, so a frame inside the remembered screenful moves // nothing at all and one outside it is scrolled just far enough // to be seen. `cursor-row` is *initialised* here too, so its own // `changed` handler never fires and this is the only thing that // can do it. init => { self.seek(); self.reveal(); } // Sized to the **whole library**, not the loaded window. The // scrollbar has to represent 23,971 images or there is no way to // reach image 20,000 — dragging it must be a real address, and the // window is swapped underneath to match. // Plus room for the selection bar, which floats over the // foot of the grid rather than sitting above it. Extending the // viewport rather than shrinking the Flickable is what keeps // the change invisible: every cell stays exactly where it was // and there is simply further to scroll, so the last row can be // brought clear of the bar instead of being trapped under it. // Same condition as the bar itself, or a running export with // the selection since cleared would leave the last row of // thumbnails trapped under a bar the grid did not know was // there. property selection-inset: (root.selected-count > 0 || root.exporting) ? 40px : 0px; viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap + self.selection-inset; // TRACES: FR-CAT-7 // Stay inside the content when the content shrinks. // // Deleting photographs makes the library shorter, and the // viewport is sized to the *whole* library — so a view that was // scrolled near the end is suddenly scrolled past it. Slint // does not pull a Flickable back on its own, so the grid went // blank: the cells were still there, above a viewport looking // at empty space below them. // // Worse than blank, it also *moved*. Cells are drawn at // `(i + offset) / columns`, and a delete re-clamps `offset` // downward to keep the window full — so the same `viewport-y` // now points at a different part of the library, and the grid // appeared to jump somewhere arbitrary. Re-seeking below is the // other half of this; this half stops the blank. changed viewport-height => { if (-self.viewport-y > max(0px, self.viewport-height - self.height)) { self.viewport-y = -max(0px, self.viewport-height - self.height); } } // Report the first fully-scrolled-past row so Rust can move the // window. Derived rather than eventful: Slint has no scroll // callback, and a `changed` handler on a derived integer fires only // when the row actually changes rather than on every pixel. property first-visible-row: max(0, floor((-self.viewport-y - Theme.gap) / (root.cell-size + Theme.gap))); changed first-visible-row => { root.scrolled(self.first-visible-row * root.columns); } // Month headings, drawn over the grid at the row where each // period begins. A separate pass rather than part of the cell, // because the heading spans the full width and a cell does not. for cell[i] in root.cells: Text { x: Theme.gap; // Sits in the gap above its row, so it labels the row // rather than displacing it. y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap) - 15px; width: parent.width - 2 * Theme.gap; text: cell.period-heading; color: Theme.ink-dim; font-size: Theme.text-sm; font-weight: 700; visible: cell.period-heading != ""; } // GESTURE: File photographs in a collection // where: Library grid // touch: Drag a photograph — or a whole selection — onto a // collection in the sidebar. Starting a drag stops // the press becoming a hold, so it cannot leave you // in selection mode. // pointer: Drag a photograph — or a whole selection — onto a // collection in the sidebar // why: The selection is what the drag carries, which is // why selecting several is worth the mode: forty // photographs file in one gesture. for cell[i] in root.cells: DragArea { // Cells are positioned at their **absolute** place in the // library, not their index in the loaded window: the window // starts at `offset`, so a cell drawn at window-index 0 belongs // wherever `offset` sits in the full grid. x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap); y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap); width: root.cell-size; height: root.cell-size; // Copy, not move: dropping into a collection files the // photograph there without taking it out of anywhere else. That // is what a join table means, and it is why the modifier-free // gesture must not be `move`. allow-copy: true; // Not the payload — the arming. See `drag-payload`. data: root.drag-payload(); // What travels under the cursor is the photograph itself — and // where several are being dragged, a stack of them. Composited // in Rust (`drag_image`), because Slint takes a single bitmap // here and cannot render a pile of thumbnails into one. // // Read on `dragging` rather than bound continuously: the // composite costs a copy per thumbnail, and the grid must not // pay it per cell per frame. drag-image: root.drag-image; changed dragging => { if (self.dragging) { root.drag-started(i); } } drag-finished(action) => { root.drag-finished(); } // Where a reorder lands. Behind the cell's content and // before it in the file, for the reason `TreeRow`'s drop // area gives: a `DropArea` only takes part in a drag, so it // does not block the presses the cell's own TouchArea // needs — but drawn first it cannot paint over the // thumbnail either. // // Present on every cell rather than wrapped in an `if`, so // the marker below can name it. `can-drop` is where the // refusal lives, which also means the cursor says no while // the user can still aim somewhere else. reorder-drop := DropArea { width: 100%; height: 100%; /// Whether the run would land after this photograph /// rather than before it. Tracked during the hover so /// the marker can move to the edge the drop will /// actually use. property after: false; can-drop(ev) => { if (!root.reorderable) { return DragAction.none; } self.after = ev.position.x > self.width / 2; return DragAction.copy; } dropped(ev) => { root.reorder-to(i, ev.position.x > self.width / 2); return DragAction.copy; } } Rectangle { // Lifted cells shrink toward their own centre, as though pulled // off the page. Inset rather than scaled: Slint has no transform // on a plain Rectangle, and insetting keeps the cell's slot in // the grid so nothing reflows mid-drag. x: cell.lifted ? 10px : 0px; y: cell.lifted ? 10px : 0px; width: parent.width - 2 * self.x; height: parent.height - 2 * self.y; animate x, y, width, height { duration: 120ms; easing: ease-out; } background: Theme.surface; border-radius: Theme.radius; // Hover only. **Selection is the inset ring below**, and // nothing out here changes when a cell is selected. // // It used to be a 2px border and a lifted fill, and a // border drawn on the outside of a 6px-padded cell eats // into the thumbnail: selecting appeared to nudge the // photograph, which is exactly the wrong feedback for a // gesture whose whole job is to say "this one". One // treatment, drawn inside, in one place. // // **Not drawn at all once there has been a finger.** A // hand has no hover to give, so on a tablet this ring can // only ever be wrong — and it is wrong in the worst way, // because it is the selection ring's own colour a pixel // thinner. `has-hover` is not reliably cleared on touch: // a release normally brings an `Exit` with it, but the one // that ends a pinch does not, so every pinch to resize the // thumbnails left a ring around whichever cell a finger // happened to have started on. The grid then showed boxes // around photographs that were not selected, with no way // to tell them from ones that were. See `touched`. border-width: cell-touch.has-hover && !root.touched ? 1px : 0px; border-color: Theme.selected-ring; clip: true; VerticalLayout { padding: 6px; spacing: 4px; Rectangle { vertical-stretch: 1; background: Theme.ground; Image { width: 100%; height: 100%; source: cell.thumbnail; image-fit: contain; visible: cell.has-thumb; // Faded while lifted, so the grid reads as the place // the photograph came *from* and the cursor as where // it is now. `colorize` would flatten it to one // tint, which loses the picture; dropping opacity // toward the ground keeps it recognisable as a ghost // of itself. // A rejected frame is held back rather than // hidden: the cull is reversible, and a photo // that vanished on one keypress would make the // gesture frightening to use. Dimming is the // cue that reads at grid scale — the cross on // the mark confirms it up close. opacity: cell.lifted ? 0.25 : (cell.flag == 2 ? 0.4 : 1.0); animate opacity { duration: 120ms; } } if !cell.has-thumb: Caption { text: cell.unavailable ? "no preview" : "…"; horizontal-alignment: center; } // "Already filed, in this many collections." Without it // there is no way to tell a filed photograph from an // unfiled one, and the user re-files what is already // in place. if cell.collection-count > 0: Rectangle { x: parent.width - self.width - 4px; y: 4px; width: 16px; height: 16px; border-radius: 8px; background: Theme.selected; opacity: 0.9; Text { text: cell.collection-count; color: Theme.ink; font-size: 9px; font-weight: 700; width: 100%; height: 100%; horizontal-alignment: center; vertical-alignment: center; } } // Pick or reject, top left — the opposite corner // from the collection badge so the two never // collide on a cell that carries both. FlagMark { x: 4px; y: 4px; flag: cell.flag; } } Label { text: cell.name; emphasised: cell.selected; overflow: elide; } } // **Selection, and the only mark of it.** // // This ring used to mean something narrower — the end a // shift-click measures from — and it was the clearest thing // on the cell, so it read as the selection to everyone who // had not written it. It also outlived what it described: an // anchor survives a deselection, so a thin box sat around // the last photograph touched with nothing selected at all, // and there was no way to tell it apart from a cell that had // stayed behind. // // So the ring is now what it already looked like. The anchor // has no mark of its own, which costs nothing it was earning: // the range gesture announces itself in the bar — "Tap the // last photograph" — and does not need the user to find // where it will measure from. // // Drawn *inside* the cell and 4px clear of the edge, so it // never touches the thumbnail and never changes a single // dimension. Selecting adds ink and moves nothing. Rectangle { visible: cell.selected; x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; background: transparent; // Two, not one: this is now the whole of the cue, and it // has to survive being read across forty cells at arm's // length against a thumbnail of any brightness. border-width: 2px; border-color: Theme.selected-ring; border-radius: Theme.radius-sm; } // Selection only. The drag is the enclosing `DragArea`'s // business, and Slint keeps a click distinct from a drag for // us — which is exactly the arbitration the hand-rolled version // had to fake with a travel threshold. cell-touch := TouchArea { mouse-cursor: pointer; // Selected on *press*, not on release: the drag that may // follow reads the selection to build its payload, and by // release the pointer is over the sidebar. // // In selection mode the press is reported as though ctrl // were held. That is not a shortcut: toggling one cell // while keeping the rest *is* what ctrl-click means, and // giving touch its own policy would be a second copy of // the rules in `collections_ui::apply_press` to keep in // step with the first. // --- which finger, and whether it finished -------- // // A pinch begins as an ordinary press. When the second // finger lands Slint closes the first one's gesture by // synthesising a `Released` at its position — that is // how a Flickable is made to let go of a scroll it had // already claimed. A TouchArea cannot tell that release // from a real one and fires `clicked`, so every attempt // to pinch-zoom the grid opened whichever photograph // the first finger happened to be resting on. // // The finger id is what separates them: the synthetic // release carries the id of the finger that *arrived*, // never the one that pressed. A mouse reports 0 for // both, so the desktop path is unchanged. // // `clicked` fires before the `up` that follows it, so // it can only raise a flag — the decision to open has // to wait for the event that names the finger. // GESTURE: Open a photograph // where: Library grid // touch: Tap it — a single tap, any length // pointer: Click it // why: A tap opens; a tap that *moved* does not. // Travel is what separates a deliberate tap // from a hand brushing past, and it is the // only thing that does: the two are the same // length. An earlier version required the // finger to dwell 120 ms instead, and that // rejected ordinary taps — a real tap is // often quicker than a brush. property down-finger: -1; property click-pending: false; // Where the press landed, so the release can tell how // far the finger travelled. // // The Flickable already cancels a press that becomes a // scroll, but only once it has claimed the gesture, // which takes more travel than a graze has. This // catches the rest: a contact that slid across the cell // and lifted was reaching for something else. property press-x: 0; property press-y: 0; /// How far a finger may slide and still be a tap. /// /// A thumb's contact patch is wider than this, so no /// stationary tap approaches it; a hand moving across /// the glass passes it within one frame. Below about /// this the number stops describing intent and starts /// describing how steady the user's hand is. property tap-slop: 12px; pointer-event(ev) => { if (ev.kind == PointerEventKind.down) { self.down-finger = ev.touch-finger-id; self.click-pending = false; self.press-x = self.mouse-x; self.press-y = self.mouse-y; // A finger, not a pointer — see `touched`. if (ev.touch-finger-id != 0) { root.touched = true; } // Not while the grid is being pinched, nor in // the moment after: this is the finger left // over from the gesture, handed back as a new // press. See `pinching`. if (!root.pinching) { // An armed range reaches Rust as shift, // which is what it is: `apply_press` reads // ctrl+shift as "add the run from the // anchor to here", and in selection mode // ctrl is already set. Sent this way rather // than as a third selection policy, so the // rules stay in one place. root.cell-pressed( i, ev.modifiers.control || root.select-mode, ev.modifiers.shift || root.ranging, ); // One shot. The range was between two taps // and the second has landed; left armed, it // would turn every tap after it into // another run. root.ranging = false; } } if (ev.kind == PointerEventKind.up) { // A *plain* click opens the image; a modified // one is purely a selection gesture and must // not navigate away from the grid the user is // building a selection in. The modifier state // is not carried here, so the press above // records it and Rust decides — `cell-clicked` // is only honoured when the press was // unmodified. if (self.click-pending && !root.pinching && ev.touch-finger-id == self.down-finger && abs(self.mouse-x - self.press-x) < self.tap-slop && abs(self.mouse-y - self.press-y) < self.tap-slop) { root.cell-clicked(i); } self.click-pending = false; // Down again, whether or not it was ever up. root.held-row = -1; root.cell-press-ended(); } // `cancel` is the important ending: the Flickable // takes the pointer as soon as the finger travels, // so without this a scroll that began on a cell // would come to rest as a long press and select it. if (ev.kind == PointerEventKind.cancel) { self.click-pending = false; // Including the cancel a starting drag sends: // by then the `DragArea` has the gesture and // `lifted` is the mark that matters, so there // is no scroll left to lose and nothing to // keep the ring open for. root.held-row = -1; root.cell-press-ended(); } } clicked => { self.click-pending = true; } // **No `double-clicked` here, deliberately.** A double // tap used to take a range in selection mode. It was // the only gesture touch had for shift-click, and // "Select to…" replaced it with a control that says // what it is about to do. Leaving both meant two quick // taps on one cell — a thing a hand does by accident — // silently selecting a run of forty photographs, with // no visible state to explain where they came from. // // Now two taps are two toggles and land back where they // started, which is the only thing a user can predict // from what is on screen. // GESTURE: Rate a photograph without opening it // where: Library grid // touch: Tap a star on the cell // pointer: Hover the cell, then click a star // keys: 0 to 5 on the selection // why: A star has to take the press without it // also reaching the cell, or every rating // throws the user into develop. // // --- the rating strip, INSIDE the cell's hit area --- // // **A child of `cell-touch`, not a sibling of it.** Two // things have to be true at once here, and only this // nesting gets both. // // A star must take the click without it also reaching // `cell-clicked`, or every rating throws the user into // develop. Children are hit-tested before the element // they sit in, and a child that accepts ends the walk // before the TouchArea's own handler runs — so the star // wins, and it wins for the same reason a later sibling // used to. // // And the strip must not vanish as the pointer arrives // at it. As a *sibling* it did: hover is tracked per // TouchArea, Slint sends `Exit` to whatever drops out of // the hit path, and the strip taking the pointer dropped // `cell-touch` out of it. `has-hover` went false, which // took `show-empty` with it, which hid the very stars // the pointer was travelling towards — on an unrated // cell they disappeared, the click landed on the cell // behind them, and the image opened. That is the whole // of the "stars vanish when I click one" report. // // An *ancestor* stays in the path: it keeps its place on // the item stack, gets no `Exit`, and during a grab it is // handed only the filter and never the event. So hover // holds for as long as the pointer is anywhere in the // cell, stars included. // // It sits over the foot of the thumbnail rather than // below it: the caption row is spoken for by the // filename, and a third row would cost thumbnail height // on every cell to show something that is usually empty. StarStrip { x: (parent.width - self.width) / 2; // Clear of the caption, which is the cell's last row. y: parent.height - self.height - 26px; rating: cell.rating; // Empty stars appear once the pointer is over the // cell, so there is something to aim at without // filling the grid with chrome. That trade only // works where there is a pointer to hover with: on // touch the strip stands open, because a control // that appears under the finger is a control that // appears too late to aim at. See `touched`. show-empty: cell-touch.has-hover || root.touched; can-trash: !root.viewing-trash; rate(n) => { root.cell-rated(i, n); } trash() => { root.cell-trashed(i); } } // The burst mark (FR-CULL-5): how many frames this // moment holds, // and the way in and out of them. // // A child of `cell-touch` for exactly the reason the // stars above are: a click here must not also reach // `cell-clicked` and throw the user into develop, and // children are hit-tested before the element they sit // in. Unlike the stars it is never hidden — a collapsed // burst is standing in for frames that are not on // screen, and there has to be something visible saying // so whether or not a pointer is anywhere near. // // Bottom left, clear of the centred star strip and of // both top corners, which the flag and the collection // badge already have. if cell.burst-count > 1: Rectangle { x: 6px; y: parent.height - self.height - 26px; width: 30px; height: 18px; // The pile behind the top card, drawn only while the // group is folded up. It is the whole of the "there // is more than one of these" cue; once the burst is // open the frames themselves say it. Rectangle { x: 3px; y: -3px; width: parent.width - 3px; height: parent.height; visible: !cell.burst-expanded; background: Theme.surface; border-radius: Theme.radius-sm; border-width: 1px; border-color: Theme.rule; } Rectangle { width: 100%; height: 100%; background: cell.burst-expanded ? Theme.selected : Theme.surface; border-radius: Theme.radius-sm; border-width: 1px; border-color: cell.burst-expanded ? Theme.selected-ring : Theme.rule; Text { width: 100%; height: 100%; horizontal-alignment: center; vertical-alignment: center; // No "of": the number is the size of the // group, and a cell this small cannot // afford a word to say so. text: cell.burst-count; color: Theme.ink; font-size: 10px; font-weight: 700; } } TouchArea { mouse-cursor: pointer; clicked => { root.burst-toggled(i); } } } // GESTURE: Choose the frame a folded burst shows // where: Library grid // touch: Open the burst, then tap the ring on the // frame you want // pointer: Open the burst, then click the ring on the // frame you want // why: A folded burst draws its earliest frame, // which is a fact about the clock and not a // judgement about the photograph — nothing // in this application ranks a frame // (FR-CULL-5). But the point of a burst is // that one of the twelve is better than the // other eleven, and the photographer is the // only one who knows which. So the choice is // offered on the frames themselves, while // they are open and side by side, which is // the one moment the alternatives are on // screen to be compared. // // Drawn on every frame of an open group and on none of // a folded one. Folded, the single cell showing *is* // the representative, so the mark would state the // obvious and the frames it offers to choose between // would not be on screen to choose from. // // Bottom right, opposite the count in the other corner: // "this many frames" at one end and "this is the one" // at the other, clear of the flag and the collection // badge in the top two. A slip between it and ★5 sets a // rating — the harmless direction for an ambiguous // press, which is why it is on this side of the cell // and not beside the trash target. // // A child of `cell-touch` for the reason the stars are: // a press here must be spent on the choice and must not // also reach `cell-clicked` and open the photograph. if cell.burst-expanded: Rectangle { x: parent.width - self.width - 6px; y: parent.height - self.height - 26px; width: 18px; height: 18px; border-radius: self.height / 2; background: Theme.surface; border-width: 1px; border-color: cell.burst-representative ? Theme.selected-ring : Theme.rule; // Ticked or empty, and that is the whole of the // state: which frame stands for the moment is said // by the presence of a mark rather than by a colour // (NFR-A11Y-3), so it survives being read at grid // scale and by an eye that does not separate the // two hues. if cell.burst-representative: Icon { name: "check"; ink: Theme.active; size: 11px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } // Live on the chosen frame too, rather than inert // there. A disabled TouchArea lets the press fall // through to the cell behind it, so tapping the one // mark that is already ticked would open the // photograph — a control that does something // unrelated when pressed twice. Pressing it instead // says "yes, this one", which is a thing the user // may mean: the default is the earliest frame, and // recording the choice keeps it through a regroup // that finds an earlier one. TouchArea { // Grown past the drawn ring to FR-UI-3's // minimum and centred on it, the way the stars // beside it are. width: max(parent.width, Theme.touch-target); height: max(parent.height, Theme.touch-target); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; mouse-cursor: pointer; clicked => { root.burst-representative-chosen(i); } } } } } // Where the run would land. A bar in the gutter beside the // cell it would sit next to, on whichever side the drop // will actually use — the trailing edge is what makes the // last place in a collection reachable, and a marker that // did not move with it would be pointing at the wrong gap // half the time. // // Last child of the `DragArea`, so it draws over the // thumbnail rather than under it. In the gutter rather than // on the cell, because a bar drawn *on* the first cell of a // row reads as belonging to that cell instead of to the // space before it. if reorder-drop.has-drag: Rectangle { x: reorder-drop.after ? parent.width + Theme.gap / 2 - 1.5px : -Theme.gap / 2 - 1.5px; y: 0; width: 3px; height: parent.height; background: Theme.active; border-radius: 1.5px; } } // **The photograph is in your hand now.** // // A ring that opens outward around the cell a press has held // long enough to pick up (see `held-row`), so the wait the // gesture needs has something to end in. Without it the user // is holding a finger on glass with no way to know whether // anything has happened — which is what made dragging feel // like a coin toss. // // **Drawn after the cells, not on one.** Cells are one `for`, // and z-order inside a `for` is the loop order — a cell that // grew past its bounds would stand over its left and top // neighbours and be cut off by its right and bottom ones, // which reads as a rendering fault rather than as a lift. One // element after the loop is above every cell by construction, // and there is only ever one photograph in the hand. // // `visible` rather than `if`, so it has somewhere to animate // *from*: an `if` builds the ring at its final size and it // would appear rather than open. // // `Theme.active` and 3px, deliberately unlike the selection // ring inside the cell — two marks that meant different things // in one colour is the mistake the hover ring made. Rectangle { property pitch: root.cell-size + Theme.gap; property reach: root.held-row >= 0 ? 5px : 0px; visible: root.held-row >= 0; x: Theme.gap + mod(root.held-row + root.offset, root.columns) * self.pitch - self.reach; y: Theme.gap + floor((root.held-row + root.offset) / root.columns) * self.pitch - self.reach; width: root.cell-size + 2 * self.reach; height: root.cell-size + 2 * self.reach; animate x, y, width, height { duration: 120ms; easing: ease-out; } background: transparent; border-width: 3px; border-color: Theme.active; border-radius: Theme.radius; } } } } } // --- the filing sheet (FR-CAT-7, FR-UI-4) ------------------------------- // // "Put these in…", for the times a drag is not available: one finger on a // scrolling grid, or a selection made across a scrub where the sidebar has // long since been closed to give the photographs the width. // // Last in the file, so it draws over the grid — and outside the // VerticalLayout above, so appearing does not reflow the header and the // cells underneath it. if root.filing: Rectangle { background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the // card's own controls sit above it. TouchArea { clicked => { root.filing = false; } } Rectangle { width: min(420px, parent.width - 2 * Theme.gap-lg); // Tall enough for the list, but never taller than the window: a // library with forty collections must still leave the buttons on // screen, which is what the Flickable inside is for. height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: root.selected-count == 1 ? "File 1 photograph in…" : "File " + root.selected-count + " photographs in…"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; wrap: word-wrap; } // Filing is a *copy*: a photograph can be in as many // collections as it belongs in, which is what a join table // means and what the drag has always done. Moving is the // exception and has to be asked for, because it is the one // that takes something away. if root.scope-label != "": Button { text: root.filing-moves ? "Moving out of " + root.scope-label : "Also keep in " + root.scope-label; active: root.filing-moves; clicked => { root.filing-moves = !root.filing-moves; } } Rectangle { height: 1px; background: Theme.rule; } Flickable { vertical-stretch: 1; // A floor, so the list is not squeezed out of existence by // the buttons around it on a short window. min-height: 120px; viewport-height: root.collections.length * (Theme.touch-target + 2px); for row[i] in root.collections: Rectangle { y: i * (Theme.touch-target + 2px); width: parent.width; // A full touch target per row, where the sidebar's // equivalent is 26px. The sidebar is a place to look; // this is a place to hit once, with a thumb, holding a // selection that took a minute to build (FR-UI-3). height: Theme.touch-target; background: row-touch.pressed ? Theme.pressed : (row-touch.has-hover ? Theme.hover : transparent); border-radius: Theme.radius-sm; // A saved filter's membership is its selector, so it // cannot be filed into — the same refusal the sidebar // makes on a drag, made here before the tap rather // than after it. opacity: row.smart ? 0.4 : 1.0; HorizontalLayout { padding-left: Theme.gap-sm + row.depth * Theme.indent; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Icon { name: row.smart ? "collection-smart" : "collection"; ink: Theme.ink-faint; size: 14px; y: (parent.height - self.height) / 2; } Text { text: row.name; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } Text { text: row.smart ? "computed" : (row.deep-count > 0 ? row.deep-count + "" : ""); color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } row-touch := TouchArea { enabled: !row.smart; clicked => { root.file-in-collection(row.id, root.filing-moves); root.filing = false; } } } // Points at the button below rather than at the sidebar's // `+`. On a tablet the sidebar is instantiated but not // drawn, so the old text sent the user to look for a // control that was not on the screen — and this sheet is // now the one place that can make a collection without // one. if root.collections.length == 0: Text { text: "No collections yet — make the first one below."; color: Theme.ink-faint; font-size: Theme.text-sm; wrap: word-wrap; width: parent.width; } } Rectangle { height: 1px; background: Theme.rule; } HorizontalLayout { spacing: Theme.gap-sm; Button { text: "Cancel"; horizontal-stretch: 1; clicked => { root.filing = false; } } // Filing a selection into a collection that does not exist // yet took four steps: make a collection, find it, select // the photographs again, add them. One press instead, // which is how a selection is usually meant. // // Here rather than on the bar because it is the answer to // a question this sheet has just asked and failed to // answer: the list above is every collection there is, and // this is what you press when none of them is the one. // // The naming sheet replaces this one rather than stacking // over it — two scrims deep is a place where dismissing // once looks like it did nothing. Button { text: "New collection…"; primary: true; horizontal-stretch: 1; clicked => { root.filing = false; root.naming = true; } } } } } } // --- the keywording sheet (FR-CAT-5, FR-CAT-6) -------------------------- // // "These are of…". Deliberately the same card, scrim and dismissal as the // filing sheet above: a user who has filed a selection already knows how // this works, and a second idiom for the same gesture would be a second // thing to learn for no gain. // // It stays open after each word, where the filing sheet closes. Filing is // one choice; keywording is usually several — "puffin", "Látrabjarg", // "2026" — and a sheet that shut after each one would have to be reopened, // and the selection re-confirmed, three times over. if root.keywording: Rectangle { background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the // card's own controls sit above it. TouchArea { clicked => { root.keywording = false; } } Rectangle { width: min(420px, parent.width - 2 * Theme.gap-lg); height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } kw-sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: root.selected-count == 1 ? "Keywords for 1 photograph" : "Keywords for " + root.selected-count + " photographs"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; wrap: word-wrap; } // Typing a word applies it, whether or not it already exists. // One field for both, because "is this keyword new?" is a // question about the catalog and not about what the user meant, // and Rust can answer it without being asked. // // The field clears itself on accept so the next word can be // typed straight after — keywording a shoot is a run of them. new-keyword := Field { placeholder: "Type a keyword and press return"; accepted(text) => { root.assign-keyword(text); self.text = ""; } } Rectangle { height: 1px; background: Theme.rule; } Flickable { vertical-stretch: 1; // A floor, so the list is not squeezed out of existence by // the field and the button around it on a short window. min-height: 120px; viewport-height: root.keywords.length * (Theme.touch-target + 2px); for word[i] in root.keywords: Rectangle { y: i * (Theme.touch-target + 2px); width: parent.width; // A full touch target per row, for the same reason the // filing sheet uses one: this is a place to hit once, // with a thumb, holding a selection that took a minute // to build (FR-UI-3). height: Theme.touch-target; background: kw-touch.pressed ? Theme.pressed : (kw-touch.has-hover ? Theme.hover : transparent); border-radius: Theme.radius-sm; HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; // Tick, dash, or nothing — the three states of // `coverage`, drawn as three different marks rather // than as two. A half-applied keyword shown as // applied is a lie about photographs the user // cannot see from here. Rectangle { width: 16px; y: (parent.height - self.height) / 2; height: 16px; border-radius: Theme.radius-sm; border-width: 1px; border-color: word.coverage == 0 ? Theme.rule : Theme.active; background: word.coverage == 2 ? Theme.active : transparent; // The dash for "some of them". A bar rather // than a tick, because a tick at half strength // reads as a rendering artefact. if word.coverage == 1: Rectangle { width: 8px; height: 2px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; background: Theme.active; } if word.coverage == 2: Icon { name: "check"; ink: Theme.surface; size: 12px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } } Text { text: word.name; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; overflow: elide; horizontal-stretch: 1; } // "3 of 12" only where it says something the mark // does not. For a word the whole selection carries, // or none of it, the mark has already said it and // the number would be noise on every row. Text { text: word.coverage == 1 ? word.selected-count + " of " + root.selected-count : (word.image-count > 0 ? word.image-count + "" : ""); color: Theme.ink-faint; font-size: Theme.text-sm; vertical-alignment: center; } } // One target for both directions. A word the selection // fully carries comes off; anything else goes on — so a // partly-applied keyword is completed rather than // removed, which is what a user tapping a dash means // nine times in ten, and the tenth is one more tap // away. kw-touch := TouchArea { clicked => { if (word.coverage == 2) { root.unassign-keyword(word.name); } else { root.assign-keyword(word.name); } } } } if root.keywords.length == 0: Text { text: "No keywords yet. Type one above to make the first."; color: Theme.ink-faint; font-size: Theme.text-sm; wrap: word-wrap; width: parent.width; } } Rectangle { height: 1px; background: Theme.rule; } Button { text: "Done"; clicked => { root.keywording = false; } } } } } // --- what is selected, and what to do with it ------------------------ // // The count and these actions used to live only in the header row, // which scrolls sideways: on a tablet "12 selected" and everything // beside it sat past the right-hand edge, so a selection was something // you could make and then not see. A selection you cannot see is one // you act on by accident. // // **Every selection verb, in one place.** For a while this bar held the // ones that *shape* a selection — clear it, extend it, select the lot — // while the ones that *act* on it stayed in the header, forty-odd pixels // from the top of a window whose bottom edge is where the count is. So // "what can I do with these twelve?" was answered in two places at // opposite ends of the screen, and the header half moved sideways every // time the answer changed. Both halves are here now: shaping first, then // acting, separated by a gap rather than by a journey. // // **Over the grid, not above it.** As a row in the vertical flow it // appeared the instant the first photograph was selected, and every // cell in the grid moved 40px — so the act of selecting shifted the // thing being selected out from under the finger, and a second tap // aimed at the neighbouring photograph landed on the row below it. // // A floating bar cannot do that: nothing above it is re-laid out, so // selecting changes what is drawn and never where. The grid's viewport // grows by the same 40px while it is there (see `selection-inset`), so // the last row can still be scrolled clear of it. // // Shown for a running export whatever the selection has since become. The // batch is what `Cancel export` refers to by then, and a run of three // hundred that could only be stopped by not touching the selection would // be a trap. if root.selected-count > 0 || root.exporting: Rectangle { y: parent.height - self.height; height: 40px; background: Theme.surface; // Swallows presses that land on the bar rather than letting them // through to the cell beneath. A bar floating over the grid is a bar a // thumb can reach for and miss into. // // Before the Flickable below, so the flick that scrolls this row is // hit-tested first and this only catches what misses it. TouchArea { } // Reads as a bar rather than as a strip of grid that happens to be // grey: the cells behind it run right up to this edge. Rectangle { height: 1px; background: Theme.rule; } // Scrolls sideways, for the reason the header and the chip row both // give at length: **a layout cannot be narrower than its children's // minimums**, so a row of ten buttons handed 768 logical pixels does // not shrink, it overruns — and the buttons past the edge are simply // gone. That was survivable while this bar held four controls and is // not now that it holds every verb. // // `min-width`, not `preferred-width`, exactly as the header row uses: // the count beside the buttons elides, so the row genuinely has a // smaller footprint than its natural one and a window with room for // the shrunk row should get it shrunk rather than scrolled. Flickable { width: 100%; height: 100%; viewport-height: self.height; viewport-width: max(self.width, selection-row.min-width); selection-row := HorizontalLayout { width: parent.viewport-width; height: parent.viewport-height; padding-left: Theme.gap; padding-right: Theme.gap; spacing: Theme.gap-sm; // No `alignment: start`, which this row used to carry. // // Slint only distributes space to `horizontal-stretch` children // under the default `stretch` alignment; under `start` every child // gets its preferred width and stretch is silently ignored. With // four controls that was invisible. With every selection verb on // the bar it is the difference between a count that gives way and // a row that can only scroll, so the count keeps its stretch and // this keeps the default. // While a range is armed the strip stops reporting and starts // instructing. The count is still true, but it is not what the // user needs to read: they have pressed something that changes // what the *next* tap means, and a mode with no visible state // is the double-tap gesture this replaces. if root.ranging: Value { text: "Tap the last photograph"; modified: true; font-weight: 600; vertical-alignment: center; overflow: elide; // Stretch is what actually lets a `Text` shrink under // pressure in Slint, not `elide` alone — see the note on the // header row's Flickable. Without it this readout keeps its // full natural width and is what pushes the buttons beside // it past the right-hand edge. horizontal-stretch: 1; } if root.ranging: Rectangle { width: Theme.gap; } // Armed by mistake, or armed and thought better of. Without // this the only way out is to tap a cell, which takes a run the // user did not want and then has to be undone by hand. if root.ranging: Button { text: "Cancel"; y: (parent.height - self.height) / 2; clicked => { root.ranging = false; } } // State rather than a label: it is what the buttons beside it // act on. // // A running export with nothing selected says so rather than // reporting "0 photographs selected", which would be true and // useless — the bar is on screen at that moment for the batch, // not for the selection. if !root.ranging: Value { text: root.selected-count == 0 ? "Exporting…" : root.selected-count + (root.selected-count == 1 ? " photograph selected" : " photographs selected"); modified: true; font-weight: 600; vertical-alignment: center; overflow: elide; // See the `ranging` readout above: stretch, not `elide`, is // what lets this collapse instead of shoving the buttons off // the end of the bar. horizontal-stretch: 1; } if !root.ranging: Rectangle { width: Theme.gap; } // The way out that is not "undo every tap". Distinct from // "Done", which leaves select mode entirely: clearing keeps the // mode, so the next selection can start straight away. // GESTURE: Drop the selection but keep selecting // where: Library grid // touch: Press Clear in the selection strip // pointer: Press Clear in the selection strip // why: Distinct from Done, which leaves the mode // entirely. Clearing keeps it, so the next // selection can start straight away. if !root.ranging && root.selected-count > 0: Button { text: "Clear"; y: (parent.height - self.height) / 2; clicked => { root.clear-selection(); } } // Everything the grid is showing. Cheap to offer and tedious to // do by hand: a scoped grid of two hundred frames is two hundred // taps otherwise, and "all of them, except those three" is a far // more common shape than the taps it took to say it. // GESTURE: Select everything the grid is showing // where: Library grid // touch: While selecting, press "Select all" // pointer: While selecting, press "Select all" // why: A scoped grid of two hundred frames is two hundred // taps otherwise, and "all of them, except those // three" is a far more common shape than the taps it // took to say it. if !root.ranging && root.selected-count > 0: Button { text: "Select all"; y: (parent.height - self.height) / 2; clicked => { root.select-all(); } } // The visible half of `ranging` — see its declaration for why // the double-tap it replaces was not good enough. Deliberately // an unfinished sentence: the ellipsis is the promise that a // second tap is coming. if !root.ranging && root.selected-count > 0: Button { text: "Select to…"; y: (parent.height - self.height) / 2; clicked => { root.ranging = true; } } // --- and here the bar turns from shaping to acting ------------- // // A gap rather than a rule: the two halves are the same subject // and the eye only needs to be told they are different sentences. if !root.ranging: Rectangle { width: Theme.gap-lg; } // TRACES: FR-CAT-7 | FR-UI-4 // File the selection in a collection without dragging it there. // The drag is the faster gesture with a pointer and impossible // with one finger on a grid that scrolls, which is the whole // reason this button exists. // // The only filing button on the bar. "New collection" used to sit // beside it, which put the same decision — where do these go? — in // front of the user twice, before they had seen the list that // answers it. The sheet asks once and offers "New collection…" // underneath its list, which is where you look after failing to // find the one you wanted. if !root.ranging && root.selected-count > 0: Button { text: "Add to collection"; y: (parent.height - self.height) / 2; clicked => { // Reset every time it opens: see `filing-moves`. root.filing-moves = false; root.filing = true; } } // TRACES: FR-CAT-5 | FR-CAT-6 // Keyword the selection. Beside the two filing buttons because // they are the same thought — these photographs are *of* // something, and they belong *with* something. if !root.ranging && root.selected-count > 0: Button { text: "Keywords"; y: (parent.height - self.height) / 2; clicked => { // Ask for the vocabulary before showing the sheet, so it // is answered against the selection as it stands now // rather than as it stood when the grid last loaded. root.keywords-opened(); root.keywording = true; } } // TRACES: FR-DEV-6 // The saved settings, beside the copied ones. // // Gated on the selection alone, unlike the paste after it: that // button needs a clipboard *this session*, where the preset list // is whatever the photographer saved last month. Requiring an // armed clipboard here would hide the saved presets behind an // unrelated action — which is the shape of bug that makes a // feature only its author knows about (FR-UI-4). if !root.ranging && root.selected-count > 0: Button { text: "Presets"; y: (parent.height - self.height) / 2; clicked => { root.open-presets(); } } // 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 bar. // // The count is in the label rather than in a confirmation: this // writes to every selected image, and "Paste to 40" said before // the click is worth more than a dialogue asking the same // question after it. if !root.ranging && root.selected-count > 0 && root.settings-armed: Button { text: "Paste to " + root.selected-count; y: (parent.height - self.height) / 2; clicked => { root.paste-settings-to-selection(); } } // Removing from a collection is only meaningful while the grid is // scoped to one. Offering it unscoped would invite the reading // "remove from the library", which nothing here does. if !root.ranging && root.selected-count > 0 && root.scope-label != "": Button { text: "Remove from collection"; y: (parent.height - self.height) / 2; clicked => { root.remove-from-collection(); } } // TRACES: FR-CAT-7 | FR-UI-4 // Where this selection is filed, and the way out of any of it. // // Unscoped as well as scoped, unlike the button above: this is the // one control that can name a collection the grid is not showing, // and hiding it outside a scope would leave "which collections is // this photograph in" answerable only by visiting each one. // GESTURE: Take photographs out of a collection // where: Library grid // touch: Select them, then "Collections…" in the selection bar // pointer: Select them, then "Collections…" in the selection bar // why: The badge on a cell says a photograph is filed in // three collections and never which. This is the sheet // that names them, and the only way out of one the grid // is not currently scoped to. if !root.ranging && root.selected-count > 0: Button { text: "Collections…"; y: (parent.height - self.height) / 2; clicked => { root.open-membership(); } } // TRACES: FR-EXP-7 | NFR-ARCH-3 // Export the selection, and stop the batch that is running. // // One button doing both, because they are the same thought a // moment apart and a separate cancel would have to appear from // somewhere — shifting the row under the pointer at the exact // moment the user is reaching for it. // // Last on the bar because it is the last thing done to a // selection, and because it is the one control here that outlives // the selection: the bar itself stays up for a running batch (see // the note on the bar) so this is always reachable while there is // something to cancel. // // The count is in the label rather than behind a confirmation, // exactly as the paste above puts it there: "Export 40" read // before the click is worth more than a dialogue asking the same // question after it. if !root.ranging: Button { text: root.exporting ? "Cancel export" : (root.export-to-server ? "Export " + root.selected-count + " to the library" : "Export " + root.selected-count); active: root.exporting; y: (parent.height - self.height) / 2; clicked => { if (root.exporting) { root.cancel-export(); } else { root.export-selection(); } } } } } } // TRACES: FR-UI-2 | FR-UI-4 // The gesture reference. Last of the sheets, and above them all, because it // is the one a user opens *because* another one confused them. if root.helping: GestureSheet { width: 100%; height: 100%; rows: root.gestures; close => { root.helping = false; } } // --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------ // // Why a sheet at all, rather than the sidebar's rename field: see `naming` // above. The short version is that on a tablet the sidebar is instantiated // but not drawn, so the rename field could take the keyboard without ever // being visible. // // The same card, scrim and dismissal as the two sheets above it, for the // same reason they share one: a user who has filed a selection knows how // this works. if root.naming: Rectangle { background: #000000CC; // Swallows the taps that miss the card, and closes. First, so the // card's own controls sit above it. TouchArea { clicked => { root.naming = false; } } Rectangle { width: min(420px, parent.width - 2 * Theme.gap-lg); height: min(name-sheet.preferred-height, parent.height - 2 * Theme.gap-lg); x: (parent.width - self.width) / 2; // A third of the way down, not centred. The field below takes the // keyboard as the sheet appears, and on a tablet the keyboard is // the bottom half of the window — a card centred in the window is a // card centred behind it. y: max(Theme.gap-lg, (parent.height - self.height) / 3); background: Theme.surface; border-radius: Theme.radius; border-width: 1px; border-color: Theme.rule; // Stops a press on the card reaching the scrim behind it. TouchArea { } name-sheet := VerticalLayout { padding: Theme.gap-lg; spacing: Theme.gap; Text { text: root.selected-count == 1 ? "New collection holding 1 photograph" : "New collection holding " + root.selected-count + " photographs"; color: Theme.ink; font-size: Theme.text-lg; font-weight: 600; wrap: word-wrap; } name-field := Field { placeholder: "Name this collection"; // The card asks one question, so the field answers the // keyboard for it. This is also what raises the on-screen // keyboard on Android — over a field that is on screen, // which is the whole difference from the old path. init => { self.take-focus(); } // Return commits, as it does in every other field here. // Guarded rather than trusting `enabled` on the button // beside it: this is a second way in and it must refuse an // empty name on its own. accepted(text) => { if (text != "") { root.collection-from-selection(text); root.naming = false; } } } HorizontalLayout { spacing: Theme.gap-sm; alignment: end; Button { text: "Cancel"; clicked => { root.naming = false; } } Button { text: "Create"; primary: true; // A collection called "New collection" is the state // this sheet exists to prevent, so Create waits for a // name rather than inventing one. enabled: name-field.text != ""; clicked => { root.collection-from-selection(name-field.text); root.naming = false; } } } } } } }